Java图片水印实战:从基础到高阶的Graphics2D半透明文字水印实现
最近在帮一个做内容电商的朋友处理图片版权问题,他们每天要处理上百张商品图,手动加水印效率太低,而且效果参差不齐。我接手后,用Java的Graphics2D重新设计了一套水印方案,不仅实现了批量处理,更重要的是让水印看起来更专业——那种若隐若现的半透明效果,既保护了版权,又不破坏图片的美感。
如果你也在为Java图片处理发愁,特别是需要批量添加文字水印的场景,这篇文章就是为你准备的。我会从最基础的Graphics2D使用讲起,逐步深入到字体抗锯齿、透明度混合、性能优化等实战技巧,最后还会分享一个可以直接用在生产环境的高阶工具类。无论你是自媒体运营者、电商平台开发者,还是需要保护设计稿版权的设计师,这些经验都能帮你节省大量时间。
1. 理解Graphics2D:Java图像处理的核心引擎
很多人一提到Java图像处理就觉得复杂,其实核心就是Graphics2D这个类。你可以把它想象成一个数字画板,而你的程序就是画家。这个画板提供了绘制线条、形状、文字,以及控制颜色、透明度、旋转等各种操作的能力。
1.1 Graphics2D的基本工作流程
处理一张图片,本质上是在内存中创建一个画布,然后把原始图片“画”上去,再在指定位置“画”上水印,最后保存这个画布。用代码来表示,核心流程是这样的:
// 1. 加载原始图片到内存
BufferedImage originalImage = ImageIO.read(new File("input.jpg"));
// 2. 创建一块和原图同样大小的画布
BufferedImage watermarkedImage = new BufferedImage(
originalImage.getWidth(),
originalImage.getHeight(),
BufferedImage.TYPE_INT_ARGB
);
// 3. 获取Graphics2D画笔
Graphics2D g2d = watermarkedImage.createGraphics();
// 4. 把原图画到画布上
g2d.drawImage(originalImage, 0, 0, null);
// 5. 设置水印样式并绘制水印
g2d.setColor(new Color(255, 255, 255, 128)); // 半透明白色
g2d.setFont(new Font("Microsoft YaHei", Font.BOLD, 36));
g2d.drawString("版权所有", 50, 50);
// 6. 释放画笔资源
g2d.dispose();
// 7. 保存处理后的图片
ImageIO.write(watermarkedImage, "jpg", new File("output.jpg"));
这个流程看似简单,但每个步骤都有坑。比如第2步创建BufferedImage时,第三个参数BufferedImage.TYPE_INT_ARGB就很重要——它决定了图片的色彩模式。TYPE_INT_RGB不支持透明度,如果你要做半透明水印,就必须用TYPE_INT_ARGB。
注意:
dispose()方法一定要调用!Graphics2D对象持有系统资源,不释放会导致内存泄漏。我见过不少线上问题都是因为忘记调用这个方法。
1.2 图片加载的两种方式与性能考量
加载图片时,你可能会从本地文件读取,也可能需要处理网络图片。这两种方式在代码实现和性能上差异很大。
本地文件加载是最直接的方式:
File imageFile = new File("/path/to/image.jpg");
BufferedImage image = ImageIO.read(imageFile);
但这里有个细节:ImageIO.read()会尝试解码整个图片文件到内存。对于大图(比如超过10MB的),这可能导致内存溢出。我建议在生产环境中加上大小检查:
public BufferedImage loadImageSafely(File file, long maxSizeBytes) throws IOException {
if (file.length() > maxSizeBytes) {
throw new IOException("图片文件过大: " + file.length() + " bytes");
}
return ImageIO.read(file);
}
网络图片加载则更复杂一些:
URL imageUrl = new URL("https://example.com/image.jpg");
BufferedImage image = ImageIO.read(imageUrl.openStream());
网络加载的问题在于不稳定——连接超时、读取超时都可能发生。我通常会用URLConnection设置超时时间:
public BufferedImage loadImageFromUrl(String urlString, int timeoutMillis) throws IOException {
URL url = new URL(urlString);
URLConnection connection = url.openConnection();
connection.setConnectTimeout(timeoutMillis);
connection.setReadTimeout(timeoutMillis);
try (InputStream stream = connection.getInputStream()) {
return ImageIO.read(stream);
}
}
两种加载方式的对比:
| 特性 | 本地文件加载 | 网络图片加载 |
|---|---|---|
| 速度 | 快(磁盘IO) | 慢(网络IO) |
| 稳定性 | 高 | 依赖网络状况 |
| 内存占用 | 可预测 | 不可预测(需流式处理) |
| 错误处理 | 简单(文件不存在) | 复杂(网络错误、格式错误) |
| 适用场景 | 批量处理本地图片 | 爬虫、内容聚合 |
在实际项目中,我建议优先处理本地文件。如果必须处理网络图片,可以考虑异步加载和缓存机制,避免阻塞主线程。
2. 半透明文字水印的核心实现技巧
半透明效果之所以看起来高级,是因为它让水印和背景图片产生了视觉上的融合。在Graphics2D中,实现半透明主要靠两个东西:Alpha通道和Composite规则。
2.1 Alpha通道:透明度的数字表达
在计算机图形学中,每个像素的颜色通常由RGBA四个分量组成:
- R:红色(0-255)
- G:绿色(0-255)
- B:蓝色(0-255)
- A:Alpha透明度(0-255)
Alpha值0表示完全透明,255表示完全不透明。我们常说的“半透明”通常对应Alpha值在100-200之间。
在Java中创建半透明颜色很简单:
// 完全不透明白色
Color opaqueWhite = new Color(255, 255, 255);
// 50%透明度的白色(Alpha=128)
Color semiTransparentWhite = new Color(255, 255, 255, 128);
// 20%透明度的黑色
Color lightBlack = new Color(0, 0, 0, 51); // 255 * 0.2 ≈ 51
但仅仅设置颜色透明度还不够。如果你直接这样用:
g2d.setColor(new Color(255, 255, 255, 128));
g2d.drawString("水印文字", x, y);
可能会发现文字边缘有锯齿,或者透明度效果不自然。这是因为还需要设置抗锯齿和混合模式。
2.2 抗锯齿与字体渲染优化
文字边缘的锯齿在放大后特别明显。Graphics2D提供了几种抗锯齿模式:
// 开启文字抗锯齿
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
// 开启图形抗锯齿(如果水印包含图形)
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
// 使用更高质量但更慢的渲染
g2d.setRenderingHint(RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY);
不同的抗锯齿模式效果差异明显。我做了个对比测试:
| 抗锯齿设置 | 文字边缘效果 | 性能影响 | 适用场景 |
|---|---|---|---|
VALUE_TEXT_ANTIALIAS_OFF | 明显锯齿 | 无影响 | 极简模式,小字号 |
VALUE_TEXT_ANTIALIAS_ON | 平滑 | 轻微 | 大多数情况 |
VALUE_TEXT_ANTIALIAS_GASP | 根据字体优化 | 中等 | 专业排版 |
VALUE_TEXT_ANTIALIAS_LCD_HRGB | 液晶屏优化 | 较高 | 网页图片、UI设计 |
对于水印场景,我推荐用VALUE_TEXT_ANTIALIAS_ON,它在效果和性能间取得了很好的平衡。
2.3 混合模式(Composite)的高级用法
AlphaComposite控制新绘制的内容如何与已有内容混合。默认是SRC_OVER,也就是新内容覆盖在旧内容之上。但对于水印,我们可能需要不同的效果:
// 标准覆盖模式(默认)
g2d.setComposite(AlphaComposite.SrcOver);
// 只绘制源像素,忽略目标(适合镂空效果)
g2d.setComposite(AlphaComposite.Src);
// 源和目标都显示(叠加效果)
g2d.setComposite(AlphaComposite.SrcAtop);
// 设置整体透明度(影响所有绘制操作)
AlphaComposite composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f);
g2d.setComposite(composite);
这里有个重要的区别:
- 颜色自带Alpha:只影响该颜色的绘制
- Composite设置Alpha:影响之后所有绘制操作
我通常这样组合使用:
// 先设置整体透明度
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.7f));
// 再设置文字颜色(颜色本身的Alpha还会起作用)
g2d.setColor(new Color(255, 255, 255, 200));
// 绘制文字
g2d.drawString("水印", x, y);
这样文字的实际透明度是0.7 * (200/255) ≈ 0.55,实现了更精细的控制。
3. 水印位置与布局的智能计算
把水印放在哪里,这是个艺术问题,也是个技术问题。直接写死坐标肯定不行——图片尺寸千变万化,水印可能跑到画面外,或者遮挡重要内容。
3.1 动态计算水印位置
我总结了几种常见的水印布局策略:
1. 九宫格定位法 把图片分成3×3的网格,水印可以放在任意交叉点:
public enum WatermarkPosition {
TOP_LEFT, TOP_CENTER, TOP_RIGHT,
CENTER_LEFT, CENTER, CENTER_RIGHT,
BOTTOM_LEFT, BOTTOM_CENTER, BOTTOM_RIGHT
}
public Point calculatePosition(BufferedImage image, FontMetrics metrics,
String text, WatermarkPosition position,
int margin) {
int textWidth = metrics.stringWidth(text);
int textHeight = metrics.getHeight();
int x, y;
switch (position) {
case TOP_LEFT:
x = margin;
y = margin + metrics.getAscent();
break;
case TOP_CENTER:
x = (image.getWidth() - textWidth) / 2;
y = margin + metrics.getAscent();
break;
case TOP_RIGHT:
x = image.getWidth() - textWidth - margin;
y = margin + metrics.getAscent();
break;
case CENTER:
x = (image.getWidth() - textWidth) / 2;
y = (image.getHeight() - textHeight) / 2 + metrics.getAscent();
break;
case BOTTOM_RIGHT: // 最常用的版权水印位置
x = image.getWidth() - textWidth - margin;
y = image.getHeight() - margin;
break;
default:
x = margin;
y = image.getHeight() - margin;
}
return new Point(x, y);
}
2. 平铺水印(防去除) 有些场景需要更安全的水印,比如证件照、设计稿:
public void addTiledWatermark(Graphics2D g2d, BufferedImage image,
String text, int spacing) {
Font font = new Font("Arial", Font.PLAIN, 24);
g2d.setFont(font);
FontMetrics metrics = g2d.getFontMetrics();
int textWidth = metrics.stringWidth(text);
int textHeight = metrics.getHeight();
// 计算需要多少行多少列
int cols = (image.getWidth() + spacing) / (textWidth + spacing);
int rows = (image.getHeight() + spacing) / (textHeight + spacing);
// 设置低透明度
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.1f));
g2d.setColor(new Color(0, 0, 0, 100));
// 平铺绘制
for (int i = 0; i <= rows; i++) {
for (int j = 0; j <= cols; j++) {
int x = j * (textWidth + spacing);
int y = i * (textHeight + spacing) + metrics.getAscent();
// 错开位置,避免过于规律
if (i % 2 == 0) {
x += textWidth / 2;
}
g2d.drawString(text, x, y);
}
}
}
3. 智能避让重要区域 对于人像图片,水印应该避开人脸:
public Point findSafePosition(BufferedImage image, FontMetrics metrics,
String text, Rectangle[] importantAreas) {
int textWidth = metrics.stringWidth(text);
int textHeight = metrics.getHeight();
// 生成候选位置(网格采样)
List<Point> candidates = new ArrayList<>();
int step = 50; // 采样步长
for (int y = 20; y < image.getHeight() - textHeight - 20; y += step) {
for (int x = 20; x < image.getWidth() - textWidth - 20; x += step) {
Rectangle textRect = new Rectangle(x, y - metrics.getAscent(),
textWidth, textHeight);
// 检查是否与重要区域重叠
boolean overlaps = false;
for (Rectangle area : importantAreas) {
if (textRect.intersects(area)) {
overlaps = true;
break;
}
}
if (!overlaps) {
candidates.add(new Point(x, y));
}
}
}
// 如果没有完全避让的位置,选择重叠面积最小的
if (candidates.isEmpty()) {
return findLeastOverlapPosition(image, metrics, text, importantAreas);
}
// 优先选择右下角(版权水印习惯位置)
return candidates.stream()
.min(Comparator.comparingInt(p ->
Math.abs(p.x - (image.getWidth() - textWidth - 20)) +
Math.abs(p.y - (image.getHeight() - 20))))
.orElse(new Point(20, image.getHeight() - 20));
}
3.2 多行水印与自动换行
有时候水印文字比较长,需要自动换行:
public void drawMultiLineWatermark(Graphics2D g2d, String text,
int maxWidth, int x, int startY) {
FontMetrics metrics = g2d.getFontMetrics();
List<String> lines = new ArrayList<>();
// 分割文本为单词
String[] words = text.split(" ");
StringBuilder currentLine = new StringBuilder();
for (String word : words) {
String testLine = currentLine.length() == 0 ? word : currentLine + " " + word;
int testWidth = metrics.stringWidth(testLine);
if (testWidth > maxWidth && currentLine.length() > 0) {
// 当前行已满,添加到行列表
lines.add(currentLine.toString());
currentLine = new StringBuilder(word);
} else {
if (currentLine.length() > 0) {
currentLine.append(" ");
}
currentLine.append(word);
}
}
// 添加最后一行
if (currentLine.length() > 0) {
lines.add(currentLine.toString());
}
// 绘制每一行
int lineHeight = metrics.getHeight();
int y = startY;
for (String line : lines) {
g2d.drawString(line, x, y);
y += lineHeight;
}
}
这个功能在处理长版权声明、公司信息时特别有用。
4. 性能优化与批量处理实战
当需要处理成百上千张图片时,性能就成了关键问题。我经历过一次生产事故:一个简单的批量水印脚本,因为没做优化,处理500张图片花了半小时,直接把服务器内存撑爆了。
4.1 内存管理与资源释放
Graphics2D处理图片时,最大的内存消耗来自BufferedImage对象。一张1920×1080的RGB图片,在内存中大约占1920×1080×3 ≈ 6MB。如果同时处理多张,内存压力很大。
正确的处理流程应该是:
public void processImageWithResourceManagement(File inputFile, File outputFile) throws IOException {
BufferedImage originalImage = null;
BufferedImage watermarkedImage = null;
Graphics2D g2d = null;
FileOutputStream fos = null;
try {
// 1. 加载原图
originalImage = ImageIO.read(inputFile);
// 2. 创建输出图像(根据需求选择类型)
int imageType = originalImage.getTransparency() == Transparency.OPAQUE
? BufferedImage.TYPE_INT_RGB
: BufferedImage.TYPE_INT_ARGB;
watermarkedImage = new BufferedImage(
originalImage.getWidth(),
originalImage.getHeight(),
imageType
);
// 3. 获取Graphics2D并设置
g2d = watermarkedImage.createGraphics();
setupGraphicsQuality(g2d);
// 4. 绘制原图
g2d.drawImage(originalImage, 0, 0, null);
// 5. 添加水印
addWatermark(g2d, watermarkedImage);
// 6. 释放Graphics2D资源
g2d.dispose();
g2d = null;
// 7. 保存图片
fos = new FileOutputStream(outputFile);
ImageIO.write(watermarkedImage, "jpg", fos);
} finally {
// 确保资源被释放
if (g2d != null) {
g2d.dispose();
}
if (fos != null) {
try { fos.close(); } catch (IOException e) { /* 忽略关闭异常 */ }
}
// BufferedImage对象会被GC回收,但大对象可以显式置null
originalImage = null;
watermarkedImage = null;
}
}
4.2 批量处理的线程池优化
对于批量任务,我推荐使用线程池,但要注意控制并发度:
public class BatchWatermarkProcessor {
private final ExecutorService executor;
private final WatermarkConfig config;
public BatchWatermarkProcessor(int threadPoolSize) {
// 创建固定大小的线程池
this.executor = Executors.newFixedThreadPool(threadPoolSize);
this.config = new WatermarkConfig();
}
public CompletableFuture<Void> processBatch(List<File> inputFiles,
File outputDir) {
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (File inputFile : inputFiles) {
File outputFile = new File(outputDir, inputFile.getName());
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
processSingleImage(inputFile, outputFile, config);
} catch (IOException e) {
throw new CompletionException(e);
}
}, executor);
futures.add(future);
}
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
}
// 根据CPU核心数设置合适的线程数
public static int getOptimalThreadCount() {
int cpuCores = Runtime.getRuntime().availableProcessors();
// IO密集型任务,可以设置比CPU核心数稍多
return Math.max(2, cpuCores * 2);
}
}
这里有几个经验值:
- CPU密集型(图片缩放、滤镜):线程数 ≈ CPU核心数
- IO密集型(磁盘读写):线程数 ≈ CPU核心数 × 2
- 混合型(水印处理):线程数 ≈ CPU核心数 × 1.5
4.3 图片格式与压缩优化
不同的图片格式对水印效果和文件大小影响很大:
| 格式 | 透明度支持 | 压缩类型 | 适用场景 | 水印建议 |
|---|---|---|---|---|
| JPEG | 不支持 | 有损压缩 | 照片、网页图片 | 白色/黑色水印,避免半透明边缘模糊 |
| PNG | 支持 | 无损压缩 | 图标、透明背景图 | 完美支持半透明水印 |
| GIF | 支持(索引色) | 无损压缩 | 动图、简单图形 | 颜色数有限,水印可能变色 |
| WebP | 支持 | 有损/无损 | 现代网页 | 效果类似PNG,文件更小 |
保存图片时的质量设置也很重要:
public void saveImageWithQuality(BufferedImage image, File outputFile,
String format, float quality) throws IOException {
if ("jpg".equalsIgnoreCase(format) || "jpeg".equalsIgnoreCase(format)) {
// JPEG需要特殊处理质量参数
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
if (writers.hasNext()) {
ImageWriter writer = writers.next();
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(quality); // 0.0-1.0
try (FileOutputStream fos = new FileOutputStream(outputFile);
ImageOutputStream ios = ImageIO.createImageOutputStream(fos)) {
writer.setOutput(ios);
writer.write(null, new IIOImage(image, null, null), param);
}
writer.dispose();
}
} else {
// PNG等格式直接保存
ImageIO.write(image, format, outputFile);
}
}
我通常这样设置质量参数:
- 网络展示:JPEG质量0.8-0.85,平衡质量和大小
- 印刷用途:PNG无损或JPEG质量0.95以上
- 缩略图:JPEG质量0.7,文件小加载快
5. 生产级水印工具类完整实现
基于以上所有技巧,我封装了一个生产环境可用的水印工具类。这个类支持:
- 单张/批量图片处理
- 多种水印样式(文字、图片、平铺)
- 智能位置计算
- 性能优化和异常处理
- 可配置的字体、颜色、透明度
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.URL;
import java.util.concurrent.*;
/**
* 生产级图片水印处理器
* 支持文字水印、图片水印、批量处理、智能定位
*/
public class ProfessionalWatermarkProcessor {
// 水印配置类
public static class WatermarkConfig {
private String text;
private Color color = new Color(255, 255, 255, 128);
private Font font = new Font("Microsoft YaHei", Font.PLAIN, 36);
private Position position = Position.BOTTOM_RIGHT;
private int margin = 20;
private float opacity = 0.7f;
private boolean antiAlias = true;
public enum Position {
TOP_LEFT, TOP_CENTER, TOP_RIGHT,
CENTER_LEFT, CENTER, CENTER_RIGHT,
BOTTOM_LEFT, BOTTOM_CENTER, BOTTOM_RIGHT
}
// Builder模式,方便链式调用
public static Builder builder() {
return new Builder();
}
public static class Builder {
private final WatermarkConfig config = new WatermarkConfig();
public Builder text(String text) {
config.text = text;
return this;
}
public Builder color(Color color) {
config.color = color;
return this;
}
public Builder font(Font font) {
config.font = font;
return this;
}
public Builder position(Position position) {
config.position = position;
return this;
}
public Builder margin(int margin) {
config.margin = margin;
return this;
}
public Builder opacity(float opacity) {
config.opacity = Math.max(0.0f, Math.min(1.0f, opacity));
return this;
}
public Builder antiAlias(boolean antiAlias) {
config.antiAlias = antiAlias;
return this;
}
public WatermarkConfig build() {
if (config.text == null) {
throw new IllegalStateException("水印文本不能为空");
}
return config;
}
}
}
/**
* 添加文字水印到单张图片
*/
public static BufferedImage addTextWatermark(BufferedImage sourceImage,
WatermarkConfig config) {
// 创建目标图像
int imageType = sourceImage.getTransparency() == Transparency.OPAQUE
? BufferedImage.TYPE_INT_RGB
: BufferedImage.TYPE_INT_ARGB;
BufferedImage watermarkedImage = new BufferedImage(
sourceImage.getWidth(),
sourceImage.getHeight(),
imageType
);
Graphics2D g2d = watermarkedImage.createGraphics();
try {
// 设置渲染质量
setupGraphicsQuality(g2d, config);
// 绘制原图
g2d.drawImage(sourceImage, 0, 0, null);
// 计算水印位置
Point position = calculateWatermarkPosition(
watermarkedImage, g2d, config
);
// 绘制水印
drawWatermarkText(g2d, config, position.x, position.y);
} finally {
g2d.dispose();
}
return watermarkedImage;
}
/**
* 批量处理图片
*/
public static void batchProcess(List<File> inputFiles, File outputDir,
WatermarkConfig config, int threadCount)
throws IOException, InterruptedException {
if (!outputDir.exists() && !outputDir.mkdirs()) {
throw new IOException("无法创建输出目录: " + outputDir);
}
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CompletionService<Void> completionService =
new ExecutorCompletionService<>(executor);
int submitted = 0;
for (File inputFile : inputFiles) {
File outputFile = new File(outputDir, inputFile.getName());
completionService.submit(() -> {
try {
BufferedImage source = ImageIO.read(inputFile);
BufferedImage result = addTextWatermark(source, config);
String format = getFormatName(inputFile.getName());
ImageIO.write(result, format, outputFile);
System.out.println("处理完成: " + inputFile.getName());
return null;
} catch (IOException e) {
System.err.println("处理失败: " + inputFile.getName() +
" - " + e.getMessage());
throw e;
}
});
submitted++;
}
// 等待所有任务完成
executor.shutdown();
for (int i = 0; i < submitted; i++) {
try {
completionService.take().get();
} catch (ExecutionException e) {
// 记录错误但继续处理其他文件
System.err.println("任务执行异常: " + e.getCause().getMessage());
}
}
}
// 私有辅助方法
private static void setupGraphicsQuality(Graphics2D g2d, WatermarkConfig config) {
if (config.antiAlias) {
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
}
// 设置透明度
g2d.setComposite(AlphaComposite.getInstance(
AlphaComposite.SRC_OVER, config.opacity
));
}
private static Point calculateWatermarkPosition(BufferedImage image,
Graphics2D g2d,
WatermarkConfig config) {
FontMetrics metrics = g2d.getFontMetrics(config.font);
int textWidth = metrics.stringWidth(config.text);
int textHeight = metrics.getHeight();
int x, y;
switch (config.position) {
case TOP_LEFT:
x = config.margin;
y = config.margin + metrics.getAscent();
break;
case TOP_CENTER:
x = (image.getWidth() - textWidth) / 2;
y = config.margin + metrics.getAscent();
break;
case TOP_RIGHT:
x = image.getWidth() - textWidth - config.margin;
y = config.margin + metrics.getAscent();
break;
case CENTER_LEFT:
x = config.margin;
y = (image.getHeight() - textHeight) / 2 + metrics.getAscent();
break;
case CENTER:
x = (image.getWidth() - textWidth) / 2;
y = (image.getHeight() - textHeight) / 2 + metrics.getAscent();
break;
case CENTER_RIGHT:
x = image.getWidth() - textWidth - config.margin;
y = (image.getHeight() - textHeight) / 2 + metrics.getAscent();
break;
case BOTTOM_LEFT:
x = config.margin;
y = image.getHeight() - config.margin;
break;
case BOTTOM_CENTER:
x = (image.getWidth() - textWidth) / 2;
y = image.getHeight() - config.margin;
break;
case BOTTOM_RIGHT:
default:
x = image.getWidth() - textWidth - config.margin;
y = image.getHeight() - config.margin;
break;
}
return new Point(x, y);
}
private static void drawWatermarkText(Graphics2D g2d, WatermarkConfig config,
int x, int y) {
g2d.setFont(config.font);
g2d.setColor(config.color);
g2d.drawString(config.text, x, y);
}
private static String getFormatName(String filename) {
String name = filename.toLowerCase();
if (name.endsWith(".jpg") || name.endsWith(".jpeg")) {
return "jpg";
} else if (name.endsWith(".png")) {
return "png";
} else if (name.endsWith(".gif")) {
return "gif";
} else {
return "jpg"; // 默认
}
}
/**
* 使用示例
*/
public static void main(String[] args) throws IOException {
// 加载图片
BufferedImage image = ImageIO.read(new File("input.jpg"));
// 创建水印配置
WatermarkConfig config = WatermarkConfig.builder()
.text("© 2024 我的公司")
.color(new Color(255, 255, 255, 150)) // 半透明白色
.font(new Font("Microsoft YaHei", Font.BOLD, 48))
.position(WatermarkConfig.Position.BOTTOM_RIGHT)
.margin(30)
.opacity(0.8f)
.antiAlias(true)
.build();
// 添加水印
BufferedImage result = addTextWatermark(image, config);
// 保存结果
ImageIO.write(result, "jpg", new File("output.jpg"));
System.out.println("水印添加完成");
}
}
这个工具类在实际项目中经过多次迭代,处理过数十万张图片。有几个设计决策值得注意:
- 使用Builder模式配置水印:避免构造函数参数过多,配置更灵活
- 资源管理严格:确保
Graphics2D.dispose()始终被调用 - 异常处理完善:单个图片处理失败不影响批量任务
- 性能考虑:支持多线程,自动选择图片类型
实际使用中,我还遇到过一些特殊情况需要处理。比如有些图片本身就有透明度通道,这时候如果还用TYPE_INT_RGB就会丢失透明信息。所以我在代码中做了判断:如果原图有透明度,就用TYPE_INT_ARGB保持透明背景。
另一个常见问题是字体缺失。在服务器环境可能没有中文字体,这时候需要手动加载字体文件:
public Font loadCustomFont(String fontPath, int style, int size) throws IOException, FontFormatException {
File fontFile = new File(fontPath);
Font customFont = Font.createFont(Font.TRUETYPE_FONT, fontFile);
return customFont.deriveFont(style, size);
}
或者使用系统字体回退机制:
public Font getSafeFont(String preferredFont, String fallbackFont, int style, int size) {
String[] fontNames = {preferredFont, fallbackFont, Font.SANS_SERIF};
for (String fontName : fontNames) {
Font font = new Font(fontName, style, size);
if (!font.getFamily().equals(Font.DIALOG)) {
return font;
}
}
return new Font(Font.SANS_SERIF, style, size);
}
水印的透明度设置也需要根据背景智能调整。深色背景用浅色水印,浅色背景用深色水印:
public Color getAdaptiveColor(BufferedImage image, int x, int y, int sampleSize) {
// 采样水印区域周围的颜色
int totalR = 0, totalG = 0, totalB = 0;
int sampleCount = 0;
for (int i = Math.max(0, x - sampleSize);
i < Math.min(image.getWidth(), x + sampleSize); i++) {
for (int j = Math.max(0, y - sampleSize);
j < Math.min(image.getHeight(), y + sampleSize); j++) {
int rgb = image.getRGB(i, j);
totalR += (rgb >> 16) & 0xFF;
totalG += (rgb >> 8) & 0xFF;
totalB += rgb & 0xFF;
sampleCount++;
}
}
if (sampleCount == 0) {
return new Color(255, 255, 255, 128); // 默认白色
}
int avgR = totalR / sampleCount;
int avgG = totalG / sampleCount;
int avgB = totalB / sampleCount;
// 计算亮度(YIQ公式)
double brightness = (avgR * 0.299 + avgG * 0.587 + avgB * 0.114) / 255;
// 根据亮度选择水印颜色
if (brightness > 0.5) {
// 背景较亮,用深色水印
return new Color(0, 0, 0, 128);
} else {
// 背景较暗,用浅色水印
return new Color(255, 255, 255, 128);
}
}
最后,处理网络图片时还需要考虑防盗链和重定向:
public BufferedImage downloadImageWithRetry(String urlString, int maxRetries)
throws IOException {
IOException lastException = null;
for (int i = 0; i < maxRetries; i++) {
try {
URL url = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
// 设置超时
connection.setConnectTimeout(10000);
connection.setReadTimeout(30000);
// 设置User-Agent,避免被屏蔽
connection.setRequestProperty("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
// 处理重定向
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_MOVED_PERM ||
responseCode == HttpURLConnection.HTTP_MOVED_TEMP) {
String newUrl = connection.getHeaderField("Location");
if (newUrl != null) {
urlString = newUrl;
continue; // 重试新URL
}
}
if (responseCode == HttpURLConnection.HTTP_OK) {
try (InputStream stream = connection.getInputStream()) {
return ImageIO.read(stream);
}
}
} catch (IOException e) {
lastException = e;
if (i < maxRetries - 1) {
try {
Thread.sleep(1000 * (i + 1)); // 指数退避
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("下载被中断", ie);
}
}
}
}
throw lastException != null ? lastException :
new IOException("下载失败,重试" + maxRetries + "次");
}
这些细节处理让水印工具更加健壮,能够应对各种实际场景。特别是在处理用户上传的图片时,你永远不知道会收到什么格式、什么质量的图片,完善的错误处理和资源管理是必须的。
我在实际项目中最常用的是右下角半透明水印,字体大小根据图片尺寸动态调整,透明度设置在0.6-0.8之间。对于电商平台的产品图,水印要足够明显以防盗用,但又不能影响商品展示;对于自媒体文章配图,水印可以更 subtle 一些,主要起品牌展示作用。
批量处理时,我一般会根据服务器配置设置合适的线程数,同时监控内存使用。如果图片特别大或者数量特别多,还会考虑分批次处理,避免内存溢出。处理完成后,生成处理报告,记录成功和失败的数量,方便排查问题。
502



被折叠的 条评论
为什么被折叠?



