
Java 图像处理工具瞬间加载 马赛克 旋转 亮度调节写这个小工具的起因其实特别简单——我手头有一批单反拍的原图每张都是 20MB 左右的 RAW 转 JPEG平时想快速翻看、随手给某些区域打码、转个方向、调个亮度动不动就要打开 PS 或者 Lightroom杀鸡用牛刀不说软件启动一次都够我泡杯茶了。陆陆续续用 Java 折腾过图像处理的朋友应该都有同感Java 自带的 ImageIO 是能用但性能一言难尽加载大图动不动就卡死更别说批量处理了。所以我就花了几个晚上用纯 Java 写了一个轻量级图像处理工具核心就四个能力瞬间加载、马赛克、旋转、亮度调节。这篇文章我把整个项目的设计思路、核心代码、踩坑记录全部整理出来。不管你是刚学 Java 想找个练手项目还是工作中确实有批量图片处理的需求这篇文章都值得你花十分钟看完。代码不复杂但里面很多优化点是我反复测试后才确定的直接抄作业完全没问题。1. 项目设计与技术选型为什么要用 Java 做图像处理1.1 定位轻量工具不碰重型框架市面上图像处理方案很多Python 有 OpenCV、PillowC 有 OpenCV 原生库甚至前端用 Canvas 也能做。但 Java 有一批特定场景拥趸——很多企业级应用本身就是 Java 写的图片处理只是其中的一个服务模块。比如 OA 系统的头像裁剪、电商后台的商品图批量处理、内容管理系统的图片审核工具用 Java 原生方案能做到零额外部署成本打成 jar 包就能跑。我的定位很清楚不做一个通用图像编辑软件而是解决快速预览 常用操作这个高频需求。这四个功能——加载、马赛克、旋转、亮度调节——正好覆盖了日常图片管理 80% 的操作场景。不用 Swing 做复杂的 UI核心逻辑全部封装成工具类既可以命令行调用也可以作为独立模块集成到其他 Java 项目中。1.2 技术路线基于 BufferedImage 和 ImageIO 原生体系Java 图像处理绕不开两个核心类BufferedImage和ImageIO。BufferedImage是 Java2D 的位图对象底层管理着像素存储区和颜色模型ImageIO负责图像文件的解码和编码。有人会问为什么不直接用 OpenCV 的 Java bindings客观说OpenCV 处理图像滤镜确实快但引入本地库会带来跨平台部署的麻烦。我这个工具希望做到一个 jar 包到处跑所以选择了 Java 原生方案配合一些手动优化技巧来弥补性能差距。实测下来只要优化策略得当原生方案处理大多数场景完全够用。1.3 核心痛点拆解四个功能各自的难点瞬间加载难点不在读文件而在解码。JPEG 解码是 CPU 密集操作一张 6000x4000 的照片ImageIO.read()直接吃满内存和时间。马赛克难点在区域处理和性能平衡。对选中区域做重采样既要保证视觉效果又不能太慢。旋转难点在角度和插值。90 度、180 度、270 度这类整数倍角度可以直接像素搬运任意角度才需要仿射变换而后者会引入质量损耗。亮度调节难点在色彩空间。RGB 直接加减会导致色偏好的做法是转 HSL 再调亮度或者用过滤器做像素运算。2. 技术原理解析这几个操作底层到底发生了什么2.1 图像加载的底层逻辑和瞬间加载的秘密为什么ImageIO.read()慢因为它会把整张图片完全解码成BufferedImage大图光内存就要吃掉一两百 MB耗时常常超过一秒。但仔细想想如果我们只是要把图片显示在一个 400x300 的预览窗口里真的需要把 6000x4000 的原始像素全部加载出来吗完全不需要。瞬间加载的核心思想叫降采样解码Subsampling Decoding。JPEG 本身就是基于 8x8 像素块的 DCT 变换编码的解码的时候完全可以只提取一部分数据而不是所有频率分量都还原。Java 的ImageReader接口提供了ImageReadParam可以对解码过程设置sourceSubsampling也就是每隔几个像素取一个点。这样解码出来的图尺寸小了 N 倍内存和耗时自然大幅下降。下面的代码展示了只读取图片尺寸信息和生成预览缩略图的方法public static Dimension getImageSize(File file) throws IOException { try (ImageInputStream in ImageIO.createImageInputStream(file)) { IteratorImageReader readers ImageIO.getImageReaders(in); if (!readers.hasNext()) { throw new IOException(无法识别的图片格式: file.getName()); } ImageReader reader readers.next(); try { reader.setInput(in); int width reader.getWidth(0); int height reader.getHeight(0); return new Dimension(width, height); } finally { reader.dispose(); } } } public static BufferedImage loadThumbnail(File file, int maxSize) throws IOException { try (ImageInputStream in ImageIO.createImageInputStream(file)) { IteratorImageReader readers ImageIO.getImageReaders(in); if (!readers.hasNext()) { throw new IOException(不支持的图片格式); } ImageReader reader readers.next(); try { reader.setInput(in); int width reader.getWidth(0); int height reader.getHeight(0); // 计算缩放比例 int maxDim Math.max(width, height); if (maxDim maxSize) { return reader.read(0); // 小图直接完整解码 } int subsamplingX (int) Math.ceil((double) width / maxSize); int subsamplingY (int) Math.ceil((double) height / maxSize); int subsampling Math.max(subsamplingX, subsamplingY); ImageReadParam param reader.getDefaultReadParam(); param.setSourceSubsampling(subsampling, subsampling, 0, 0); return reader.read(0, param); } finally { reader.dispose(); } } }这里有个细节必须提醒setSourceSubsampling只对 JPEG、PNG 等部分格式生效而且subsampling值不宜过大一般不要超过 8不然解码出来的缩略图质量会明显下降。更稳妥的做法是结合ImageReadParam的setSourceRegion先截取中心区域再降采样质量更好。实测下来一张 6000x4000 的照片ImageIO.read()需要约 1200ms而用这种降采样方式生成 400px 缩略图只需要 80ms 左右快了差不多 15 倍。2.2 马赛克算法为什么去马赛克只是伪命题马赛克的处理原理很直观把指定区域切分成若干个小格子每个格子内的所有像素都取平均值用一个颜色块代替原来的多样性像素分布。这个过程本质上是区域信息的丢失——一旦取完平均值格子内原本丰富的纹理细节和颜色差异就全部被抹平了。理解了这一点就明白网上那些去马赛克工具为什么基本不靠谱。马赛克是不可逆操作理论上你有 N 种可能的原始像素组合都能产生相同的结果块想从平均值反推出原始数据数学上就是欠定问题。所谓 AI 去马赛克本质上是猜用训练好的模型根据上下文推测最可能的纹理但绝不是真正还原。所以给图片敏感信息打码时放心打只要马赛克格子够大安全性是有保障的。我的马赛克实现用了最经典的均值填充算法public static BufferedImage mosaic(BufferedImage source, int x, int y, int width, int height, int blockSize) { BufferedImage result new BufferedImage(source.getWidth(), source.getHeight(), source.getType()); result.createGraphics().drawImage(source, 0, 0, null); // 裁剪处理区域防止越界 int startX Math.max(0, x); int startY Math.max(0, y); int endX Math.min(source.getWidth() - 1, x width); int endY Math.min(source.getHeight() - 1, y height); for (int by startY; by endY; by blockSize) { for (int bx startX; bx endX; bx blockSize) { // 计算当前块的实际边界 int blockEndX Math.min(bx blockSize, endX); int blockEndY Math.min(by blockSize, endY); // 计算块内像素平均值 long sumR 0, sumG 0, sumB 0; int count 0; for (int py by; py blockEndY; py) { for (int px bx; px blockEndX; px) { int rgb result.getRGB(px, py); 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 avgColor (avgR 16) | (avgG 8) | avgB; // 填充块 for (int py by; py blockEndY; py) { for (int px bx; px blockEndX; px) { result.setRGB(px, py, avgColor); } } } } return result; }代码虽然简单但有两个性能隐患要注意。第一大量调用getRGB/setRGB是非常慢的每个像素都要做对象包装和类型检查。优化方案是先把目标区域的像素批量读取到int[]数组里在数组上做运算后再批量写回性能能提升几个数量级。第二blockSize越大马赛克颗粒感越强隐私保护效果越好建议处理人脸等敏感区域时至少用 16 以上的值。2.3 旋转方案倍数旋转与任意角度旋转分开处理旋转功能我分了两个层面实现90 度整数倍旋转和任意角度旋转。为什么要分开因为二者的算法复杂度完全不同。90 度整数倍旋转不需要任何插值运算纯粹是像素坐标映射。比如 90 度顺时针旋转原来的(x, y)坐标会变成(height - 1 - y, x)遍历一遍像素就能完成速度极快内存占用也不大。任意角度旋转则需要用到AffineTransform仿射变换。仿射变换在数学上就是对坐标做线性变换加上平移旋转矩阵长这样[cosθ -sinθ]、[sinθ cosθ]。问题在于旋转后的目标像素坐标往往不是整数这时候就得决定取哪个原始像素——这就是插值。Java 提供了三种插值方案TYPE_NEAREST_NEIGHBOR最近邻插值、TYPE_BILINEAR双线性插值、TYPE_BICUBIC双三次插值。最近邻最快但锯齿明显双三次最平滑但耗时翻倍。我实测下来对一般业务场景双线性插值是性价比最好的选择。90 度旋转的代码实现public static BufferedImage rotate90(BufferedImage source, int times) { int width source.getWidth(); int height source.getHeight(); // times 取模 4避免多余运算 times ((times % 4) 4) % 4; if (times 0) { return source; } // 90 度和 270 度需要交换宽高 boolean swap (times 1 || times 3); int newWidth swap ? height : width; int newHeight swap ? width : height; BufferedImage result new BufferedImage(newWidth, newHeight, source.getType()); for (int y 0; y height; y) { for (int x 0; x width; x) { int rgb source.getRGB(x, y); int newX, newY; switch (times) { case 1: // 顺时针 90 度 newX height - 1 - y; newY x; break; case 2: // 180 度 newX width - 1 - x; newY height - 1 - y; break; default: // 顺时针 270 度 newX y; newY width - 1 - x; break; } result.setRGB(newX, newY, rgb); } } return result; }坦率地说这个逐像素setRGB版本是为了演示坐标映射逻辑商业级使用我会换成WritableRaster直接操作像素数组。对方向的判断上我建议先用getWidth()和getHeight()确认原图比例输出时再决定是生成新尺寸的画布还是保持固定画布。动态生成新尺寸的画布更符合用户预期旋转 90 度后横图变竖图原来放不下的内容不会被裁掉。2.4 亮度调节像素加减法的陷阱与正解亮度调节听起来是所有功能里最简单的不就是每个像素的 RGB 分量加一个偏移量吗真这么干往往会翻车——你调出来的图片要么偏色要么过曝一片死白。原因在于RGB 三个通道的数值被人为割裂地调整破坏了原有的色彩平衡。举个实际例子假设某个像素颜色是(100, 50, 20)这是个偏橙的颜色。如果把每个分量都加 50变成(150, 100, 70)这个颜色变亮的同时饱和度也发生了不可控的变化色相甚至可能漂移。正确的思路有两种第一种是用RescaleOp做线性变换newValue scale * original offset。这种方案可以保持通道间的比例关系但 offset 加得太大同样会裁切高光。第二种是先转成 HSL色相、饱和度、亮度色彩空间只调整 L 分量再转回 RGB。这样色彩感知最好暗部和高光的过渡都自然缺点是每个像素都要做转换运算吞吐量会低一些。我最终采用的是第二种方案的简化版因为工具面向的是照片预览场景颜色准确度优先。应用BufferedImageOp家族的LookupOp可以避免逐像素操作的开销因为它是经过优化的原生实现public static BufferedImage adjustBrightness(BufferedImage source, int delta) { // delta 范围建议在 -255 到 255 之间 BufferedImage result new BufferedImage(source.getWidth(), source.getHeight(), source.getType()); // 构建亮度查找表 short[] brighten new short[256]; for (int i 0; i 256; i) { int value i delta; brighten[i] (short) Math.min(255, Math.max(0, value)); } LookupOp op new LookupOp(new ShortLookupTable(0, brighten), null); op.filter(source, result); return result; }这段代码里ShortLookupTable会把原图中的每个像素值作为索引在查找表中找到对应的输出值。比如delta 30原像素值 100 就会变成 130这个过程由 Java2D 内部高效完成比手写双重循环至少快 5 倍。还有一个细节很多人不知道亮度调节最好在 RGB 三个通道上用同一个查找表而不是分别构造。因为LookupOp支持对每个 band通道单独建表如果你不小心给每个通道做了不同的映射图片就废了。我这块踩过坑调试了半天才发现是 table 数组的方向问题。3. 实操过程从零搭一个可用工具3.1 环境准备与项目骨架开发环境很简单JDK 8 及以上即可我用的 JDK 17不需要引入任何第三方图像库。项目结构上我建了一个 Maven 工程只用了标准的 Java 内置库。src/main/java └── com/example/imagetool ├── ImageUtils.java # 图像处理核心类 ├── ThumbnailCache.java # 缩略图缓存 └── Main.java # 命令行入口命令行入口支持这样调用java -jar image-tool.jar load photo.jpg --preview 400 java -jar image-tool.jar mosaic photo.jpg --x 100 --y 50 --w 200 --h 100 --size 20 java -jar image-tool.jar rotate photo.jpg --angle 90 java -jar image-tool.jar bright photo.jpg --delta 30在动手写完整工具之前我建议你先在一个空项目里把四个核心工具方法跑通确认输出结果符合预期再考虑命令行参数解析和文件批量遍历这些外围功能。核心逻辑越早验证后面出问题的概率越低。3.2 瞬间加载落地缩略图缓存与异步加载只有降采样解码还远远不够要做到瞬间加载还必须引入缓存策略。最简单的做法是首次打开图片时生成一个内存缩略图并缓存下来后面再打开同一张图直接命中缓存不用重新解码。我把缓存实现成ConcurrentHashMapLong, BufferedImagekey 是文件路径的哈希值和最后修改时间的组合。为什么要把最后修改时间加进去因为如果用户替换了同名文件旧缓存必须失效。只拿路径做 key 的话你看到的永远是对旧图片的解码结果。public class ThumbnailCache { private final MapCacheKey, BufferedImage cache new ConcurrentHashMap(); private final int maxEntries 200; // 防止内存被撑爆 public BufferedImage getThumbnail(File file, int maxSize) throws IOException { CacheKey key new CacheKey(file.getAbsolutePath(), file.lastModified(), maxSize); BufferedImage cached cache.get(key); if (cached ! null) { return cached; } BufferedImage thumb ImageUtils.loadThumbnail(file, maxSize); if (cache.size() maxEntries) { // 简单清理移除第一个元素 cache.remove(cache.keySet().iterator().next()); } cache.put(key, thumb); return thumb; } private record CacheKey(String path, long lastModified, int maxSize) {} }这个缓存策略对翻看大量图片的场景简直是救星。第一次进入某个文件夹时400px 缩略图大约需要 80ms 一张翻到下一张后再切回来就是毫秒级响应。如果要做批量缩略图生成配合Executors.newFixedThreadPool做并发预加载可以用满多核 CPU把 100 张图的缩略图生成时间压到 3 秒以内。唯一要注意的是缓存数量必须有上限否则图片一多程序先被自己的缓存干趴下了。3.3 马赛克功能从整图打码到区域打码很多初学朋友写马赛克只会整图处理但实际使用场景中更多是给局部打码比如人脸、车牌、身份证号。区域打码的关键是坐标定位。我给命令行入口传了--x/--y/--w/--h四个参数分别表示区域的左上角坐标和区域宽高。这看起来简单但有一个隐蔽问题坐标空间必须一致。用户看到的坐标是经过缩放显示的坐标比如 400px 缩略图上的坐标而实际打码操作需要作用在原始分辨率图上。中间的换算公式是// 用户点击缩略图上的 (clickX, clickY)对应原图坐标 int srcX clickX * originalWidth / thumbnailWidth; int srcY clickY * originalHeight / thumbnailHeight;如果省略这一步你会发现打码区域总是不准要么偏了要么打错了地方。这个问题我没少被朋友吐槽过他们直接拿缩略图坐标往原图上套结果脸上的马赛克糊到了背景墙上。另一个值得做的改进是支持多区域打码一次处理多个矩形区域。实现方式很简单mosaic方法接收一个ListRectangle按区域依次执行但要注意区域重叠的情况——重叠区域后处理的块会覆盖先处理的块如果两个区域的blockSize不同边界会看起来不整齐。我的做法是限定整个图统一blockSize重叠部分后面的区域覆盖前面的视觉上没问题。3.4 旋转和亮度调节批处理与流水线单张图片处理只是基本功。实际项目里最常见的需求是批处理——把某个目录下的所有图片统一旋转 90 度、降低亮度输出到一个新目录。这里我封装了一个简单的流水线处理器按顺序执行多个BufferedImageOp。public static void batchProcess(File sourceDir, File targetDir, ListImageOperator operators) { File[] files sourceDir.listFiles((dir, name) - { String lower name.toLowerCase(); return lower.endsWith(.jpg) || lower.endsWith(.jpeg) || lower.endsWith(.png); }); if (files null) return; for (File file : files) { try { BufferedImage img ImageIO.read(file); for (ImageOperator op : operators) { img op.apply(img); } ImageIO.write(img, jpg, new File(targetDir, file.getName())); } catch (IOException e) { // 单个文件失败不应该终止整个批次 System.err.println(处理失败: file.getName() - e.getMessage()); } } }这里我特意强调单个文件处理失败不能中断整个批次。批量任务遇到一张损坏的图片、一个非法参数太常见了捕获异常并记录日志继续处理下一个文件才是工程化的做法。为了防止生产者消费者模式把内存耗尽流式逐文件处理足够满足大部分需求不需要把所有图片一次性读入内存。如果你把旋转、马赛克、亮度调节都串联起来这一步其实是在设计一个微型图像处理流水线。要保证每个操作都是纯函数式传入BufferedImage返回新的BufferedImage不要修改原始对象。因为后续操作可能依赖原始图的状态一旦原始数据被污染脏数据会一路传递下去。这也是我上面rotate90方法里times 0时返回原对象而是不拷贝的原因——原始对象未被修改就是安全的。3.5 界面集成Swing 还是纯命令行我之前主要做的是命令行工具但如果是做桌面工具Java Swing 依然是零依赖情况下最现实的选择。主界面就三块左侧是缩略图列表用 120px 缩略图渲染中间是预览大图区配合鼠标滚轮缩放和拖拽查看右侧是操作按钮区。写桌面版的时候有个非常重要的经验不要在 EDT事件分发线程里做图像解码。Swing 的界面绘制和事件响应都跑在 EDT 上如果你在这个线程里调用ImageIO.read()加载大图界面就会卡死表现为窗口无响应、按钮点了没反应。正确做法是建立一个后台任务解码完成后用SwingUtilities.invokeLater回到 EDT 更新界面。Executors.newSingleThreadExecutor().execute(() - { try { BufferedImage img ImageUtils.loadThumbnail(file, previewSize); SwingUtilities.invokeLater(() - { imageLabel.setIcon(new ImageIcon(img)); statusLabel.setText(加载完成: (System.currentTimeMillis() - start) ms); }); } catch (IOException e) { SwingUtilities.invokeLater(() - statusLabel.setText(加载失败)); } });这个模式对 Java 桌面应用来说已经是老生常谈了但真按捺不住想直接在 EDT 上读图的新手太多了这里再强调一遍解码必须异步否则瞬间加载永远只是理论上美好。4. 性能对比与常见问题排查4.1 实测数据优化前后对比我拿一张 6000x4000 的 JPEG约 9.2MB做了基准测试测试环境是 i5-10300H 处理器、16GB 内存、JDK 17。结果如下操作优化前耗时优化后耗时备注加载完整原图1248ms1248ms完整解码无法规避生成 400px 缩略图1248ms82ms降采样解码 缓存马赛克10px 块全图860ms143ms数组批量操作替代 getRGB旋转 90 度210ms95ms直接映射 WritableRaster亮度 30650ms58msLookupOp 替代逐像素循环注意表格里的马赛克优化主要收益来自把两层getRGB/setRGB循环改写为Raster像素数组操作。如果你认真优化这个数字还能继续往下压。图中的显示预览场景优化后通常能控制在 100ms 以内这就是用户感知层面的瞬间加载。4.2 高频问题一图片类型支持不全读不了某些 PNGJava 的 ImageIO 默认支持的格式是 JPEG、PNG、GIF、BMP 和 WBMP。WebP 这种现代格式是不支持的TIFF 只有部分 JDK 版本支持。如果你的图片打不开先不要怪代码先确认一下格式。商业场景如果必须支持 WebP要么引入第三方编解码库要么请用户先把图转为 PNG/JPEG。4.3 高频问题二处理大图时内存溢出 OutOfMemoryError这是最常遇到的坑。一张 12000x8000 的图BufferedImage的TYPE_INT_RGB每个像素占 4 字节解出来就是 12000 x 8000 x 4 384MB再做的任何处理都需要额外的复本内存。所以必须合理设置 JVM 最大堆内存java -Xmx2g -jar image-tool.jar ...同时养成好习惯处理完的BufferedImage及时置null让 GC 能回收。如果你有批处理需求更要注意按文件流式处理不要一个ListBufferedImage把所有图片都装进去那纯属自杀式设计。4.4 高频问题三ImageIO.write保存 JPEG 后颜色偏红了ImageIO.write保存 JPEG 时BufferedImage的颜色模型如果和 JPEG 的 YCbCr 色彩空间不一致就可能出现色调偏移。最稳妥的办法是保存前把图片转换为TYPE_INT_RGBpublic static void saveAsJpeg(BufferedImage img, File out) throws IOException { BufferedImage rgb new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_RGB); rgb.createGraphics().drawImage(img, 0, 0, null); ImageIO.write(rgb, jpg, out); }这个转换也顺便解决了 PNG 带透明通道保存为 JPEG 时会变黑的问题。JPEG 本身不支持透明你必须先铺一个白色背景再把带透明通道的图画上去否则透明部分保存出来就是黑色。4.5 实操速查表经验与避坑清单问题/场景解决方案大图加载慢、内存占用高降采样解码 缩略图缓存 限制缓存条目数马赛克块越大越好不是块过大会丢失目标信息建议 8~24px 合理范围getRGB/setRGB循环太慢改用WritableRaster.setPixels/getPixels批量操作旋转 90 度后图片空边动态调整目标画布尺寸而不是固定画布亮度调节导致色偏改为 HSL 空间只调整亮度分量或统一的 LookupOp 表批量处理一张失败中断整个流程单文件异常捕获并日志记录继续处理下一张Swing 界面加载图片卡死后台线程解码 SwingUtilities.invokeLater回 UIJPEG 保存后颜色异常先转TYPE_INT_RGB再写文件无法识别的图片格式检查扩展名是否为默认支持的格式必要时转换格式4.6 还需注意的坐标与边界问题区域马赛克的坐标传参我强烈建议做一次合法性校验。有些情况用户传入的x width超出了图片宽度或者y height超出了图片高度。当这些值不规范时要么裁剪到边界要么干脆抛异常提示用户。我在代码里选择裁剪到边界因为业务上超出部分不打码比整个任务失败更友好。同样旋转角度为负数的情况也需要提前处理。用户传-90度我们当然能理解成顺时针 270 度但不能让仿射变换收到负角度参数后做出奇怪的事情。使用((angle % 360) 360) % 360把角度周期化统一处理正负角度就少了一道分支判断。5. 项目还可以怎么扩展这个工具目前能跑、能打、能转、能调亮度但对于一个完整可用的图像处理工具还是有不少可以继续加料的地方。如果大家有兴趣往深做我个人建议挑下面几个方向试试。批量重命名 压缩很多场景下处理完图片还要顺手输出一份压缩版用于网络分享。把压缩质量参数float quality透传给 JPEG 写入器JPEGImageWriteParam就能在保存时控制体积。80% 质量通常人眼看不出差异但文件体积能缩小 60% 以上。滤镜扩展在现有框架上增加一个ImageOperator接口每次新增滤镜只需实现apply方法。灰度、反色、锐化、水印都是几十行代码的事。这套流水线架构可以一直复用。EXIF 信息读取手机照片默认带了拍摄方向、相机型号、GPS 信息用metadata-extractor这类库能直接读到。有了 EXIF 的旋转方向字段就能自动纠正手机竖拍却横着显示的问题——这其实是我一直想加的功能有些手机拍的竖图不转一下怎么看怎么别扭。导出 GIF 预览如果需要给客户演示效果可以逐帧处理并合成 GIF。从个人经验来说这种小工具项目的价值从来不在代码量而在解决实际问题的针对性。我周围同事经常为了一张图的处理专门打开重型软件明明 30 秒能搞定的事拖成了 5 分钟。工具类的东西做到快、稳、够用就已经成功了一大半。我实际使用中发现这个工具用顺手以后最惊喜的反而不是马赛克或者亮度调节这些炫功能而是那个不起眼的瞬间加载。翻看照片的流畅感直接影响了我用不用这个工具。所以如果你只想先写一个功能我建议优先做缩略图加载和缓存把这个基本盘做扎实了其他功能都是锦上添花。最后再分享一个小技巧给所有图像操作方法统一加上一个BufferedImage source、返回新BufferedImage的签名风格后续哪怕要做撤销/重做功能也只需要保存操作前的图片快照就行架构上完全不慌。