关注我的公众号:【编程朝花夕拾】,可获取首发内容。

01 引言
老程序员已经做过使用Java自己编写图片验证码的项目,然而随着技术的发展,三方库的增强。滑动验证码、旋转验证码等更加方便的验证码出现,逐渐取代了传统的验证码。尤其当前12306图片验证码为了防止黄牛刷票,热搜一个接一个......
当然也不乏有很多成熟的传统验证码工具,已经很少自己去写验证码了,重复造轮子完全没有必要。验证码可能不需要了写了,但是这项画图的技术依然有他的用武之地。
我们一起来看看今天的需求!
02 契机
这几天一直忙着紧急项目,都没有时间更文。这段时间就是研究了画图这个玩意,主要用来标记图片上的瑕疵点。瑕疵点过多,标注的内容还不能重叠,真实让人想破了头。我们先看看标注的结果:

怎么来画出这样的标注图呢?我们一步步拆解。
03 步骤拆解
我们需要用到的API类:
BufferedImageGraphics2DFontFontMetrics
其实说白了就是数学问题,三角函数问题。
3.1 瑕疵点花圆
主要用来标注目标点的位置,以坐标点为圆心,画一个适合半径的圆。我的图片尺寸是1600*1200,我取的半径是15。
伪代码:
java
BufferedImage image = ImageIO.read();
Graphics2D g2d = image.createGraphics();
// 设置画笔颜色
g2d.setColor(Color.ORANGE);
// 以目标点(x,y)为圆心画一个指定半径的圆饼填充
g2d.fillOval(x - radius, y - radius, radius * 2, radius * 2);
fillOval()用来填充图形。

参数为左上角的坐标,所以这里都要减去半径才是画笔落笔的地方。宽度和高度分别指直径。
3.2 箭头辅线
剑斗分为两种:带辅助线和不带辅助线。我们以带辅助线的为例。
带辅助线的箭头如图上的瑕疵点02。箭头其实也是线,我们需要确定箭头的角度大小,而箭头的方向取决于辅助线的方向。为了计算的方便,我们设定为的辅助线的角度为45°,这样对应的X轴和Y轴的值都相等。
画辅助线,我们需要知道两个坐标即可。标记点坐标(x,y),向右上方45°延伸,位于第一象限。假如延伸的距离为x0,那么辅助线终点的位置(x+x0, y-x0)。Y轴为什么要减,是因为画布的坐标(0,0)位于左上角,Y轴朝下为正。
绘制辅助线:
java
g2d.draw(new java.awt.geom.Line2D.Double(x, y, x+x0, y-x0));
计算箭头的坐标:
java
double angle = Math.atan2(y1 - y2, x1 - x2);
double x3 = x1 - arrowSize * Math.cos(angle - Math.PI / 12);
double y3 = y1 - arrowSize * Math.sin(angle - Math.PI / 12);
double x4 = x1 - arrowSize * Math.cos(angle + Math.PI / 12);
double y4 = y1 - arrowSize * Math.sin(angle + Math.PI / 12);
这里是利用三角函数计算箭头的相对坐标。arrowSize箭头大小,而Math.PI / 12表示15°,因为Math.PI等于180°,表示箭头与辅助线的夹角。
绘制箭头:
java
// 绘制箭头
g2d.draw(new java.awt.geom.Line2D.Double(x1, y1, x3, y3));
g2d.draw(new java.awt.geom.Line2D.Double(x1, y1, x4, y4));
填充箭头:
java
// 箭头三个点的坐标
Polygon arrowHead = new Polygon();
arrowHead.addPoint((int) x1, (int) y1);
arrowHead.addPoint((int) x3, (int) y3);
arrowHead.addPoint((int) x4, (int) y4);
// 划线填充
g2d.fill(arrowHead);
3.3 底色
绘制内容的底色,防止图片颜色造成干扰。
java
// 标注的内容增加底色
g2d.setColor(Color.GRAY);
g2d.fillRect((int)dx, (int)dy, Math.abs(contentWidth), fontHeight);

我们填充矩形,(dx, dy)依然是左上角的位置。
3.4 标注内容
标注内容我们也需要画上底线,我们以辅助线的末端作为起点(x2,y2),contentWidth为内容的宽度。
java
g2d.draw(new java.awt.geom.Line2D.Double(x2, y2, x2 + contentWidth, y2));
g2d.setColor(Color.WHITE);
g2d.setFont(font);
g2d.drawString(content, ltx, lty);
(ltx,lty)同样为左上角的位置。
04 完整代码
这里的代码是我封装的一个小工具。里面简单的画图可以实现了,但是还缺少碰撞检测以及优化避让。先分享给大家。
java
public class FastDraw {
/** 图片源 */
private BufferedImage image;
private Font font;
/** 画布 */
private Graphics2D g2d;
/** 画布/边界宽高 */
private int imageWidth;
private int imageHeight;
/** 字体信息和高度 */
private FontMetrics fontMetrics;
private int fontHeight;
/** 标记点半径 */
private int radius = 15;
public FastDraw(BufferedImage image, Font font) {
Assert.notNull(image, "image is null");
Assert.notNull(font, "font is null");
this.image = image;
this.font = font;
this.imageWidth = image.getWidth();
this.imageHeight = image.getHeight();
this.g2d = image.createGraphics();
this.fontMetrics = g2d.getFontMetrics(font);
this.fontHeight = fontMetrics.getHeight();
}
/**
* 绘制标注内容:Callout Content
*
* x1, y1 : 起点坐标
* x0 : 辅助斜线X轴长度
* content : 标注内容
* arrowSize : 箭头大小
* quadrant : 象限(默认第一象限:逆时针)
*
* 返回值: 辅助线末端坐标
**/
public void drawArrow(double x, double y, double x0, double arrowSize, String content, int quadrant) {
double x1 = initBoundaryX(x);
double y1 = initBoundaryY(y);
// 绘制箭头的圆点
g2d.setColor(Color.ORANGE);
// 以损伤点为中心画一个radius为半径的圆
g2d.fillOval((int) x1 - radius, (int) y1 - radius, radius * 2, radius * 2);
// 绘制箭头使用白色
g2d.setColor(Color.WHITE);
int contentWidth = getWordWidth(content);
// 绘制箭头直线
double x2 = 0.0, y2 = 0.0;
// 绘制标注
int ltx = 0;
switch (quadrant) {
case 1:
x2 = x1 + x0;
y2 = y1 - x0;
ltx = (int)x2;
break;
case 2:
x2 = x1 - x0;
y2 = y1 - x0;
contentWidth = -contentWidth;
ltx = (int)x2 + contentWidth;
break;
case 3:
x2 = x1 - x0;
y2 = y1 + x0;
contentWidth = -contentWidth;
ltx = (int)x2 + contentWidth;
break;
case 4:
x2 = x1 + x0;
y2 = y1 + x0;
ltx = (int)x2;
}
// 左上y:内容向上字体高度的1/4
int lty = (int) y2 - fontHeight/4;
// 底色起点坐标
double dx = ltx;
double dy = (int) y2 - fontHeight;
// 绘制直线
g2d.draw(new Double(x1, y1, x2, y2));
// 计算箭头角度
double angle = Math.atan2(y1 - y2, x1 - x2);
double x3 = x1 - arrowSize * Math.cos(angle - Math.PI / 12);
double y3 = y1 - arrowSize * Math.sin(angle - Math.PI / 12);
double x4 = x1 - arrowSize * Math.cos(angle + Math.PI / 12);
double y4 = y1 - arrowSize * Math.sin(angle + Math.PI / 12);
// 绘制箭头
g2d.draw(new Double(x1, y1, x3, y3));
g2d.draw(new Double(x1, y1, x4, y4));
// 创建箭头多边形并填充
Polygon arrowHead = new Polygon();
arrowHead.addPoint((int) x1, (int) y1);
arrowHead.addPoint((int) x3, (int) y3);
arrowHead.addPoint((int) x4, (int) y4);
g2d.fill(arrowHead);
g2d.draw(new Double(x2, y2, x2 + contentWidth, y2));
// 标注的内容增加底色
g2d.setColor(Color.GRAY);
g2d.fillRect((int)dx, (int)dy, Math.abs(contentWidth), fontHeight);
// 绘制标注
g2d.setColor(Color.WHITE);
g2d.setFont(font);
g2d.drawString(content, ltx, lty);
}
/***
* 绘制平铺内容
*
**/
public void drawTile(double x1, double y1, String content) {
x1 = initBoundaryX(x1);
y1 = initBoundaryY(y1);
// 绘制箭头的圆点
g2d.setColor(Color.ORANGE);
// 以损伤点为中心画一个radius为半径的圆
g2d.fillOval((int) x1 - radius, (int) y1 - radius, radius * 2, radius * 2);
// 底色坐标
int[] x, y;
int contentWidth = getWordWidth(content);
if (x1 + contentWidth <= imageWidth) {
// 未超出边界,箭头方向朝左←
// 左上
int lux = (int)x1 + fontHeight;
int luy = (int)y1 - fontHeight / 2;
// 左下
int ldx = (int)x1 + fontHeight;
int ldy = (int)y1 + fontHeight / 2;
// 右上
int rux = (int)x1 + fontHeight + contentWidth;
int ruy = (int)y1 - fontHeight / 2;
// 右下
int rdx = (int)x1 + fontHeight + contentWidth;
int rdy = (int)y1 + fontHeight / 2;
x = new int[]{(int) x1, lux, rux, rdx, ldx};
y = new int[]{(int) y1, luy, ruy, rdy, ldy};
}else {
// 超出边界箭头朝右→
// 左上
int lux = (int)x1 - fontHeight- contentWidth;
int luy = (int)y1 - fontHeight / 2;
// 左上
int ldx = (int)x1 - fontHeight - contentWidth;
int ldy = (int)y1 + fontHeight / 2;
// 左上
int rux = (int)x1 - fontHeight;
int ruy = (int)y1 - fontHeight / 2;
// 左上
int rdx = (int)x1 - fontHeight;
int rdy = (int)y1 + fontHeight / 2;
x = new int[]{lux, rux, (int) x1, rdx, ldx};
y = new int[]{luy, ruy, (int) y1, rdy, ldy};
}
g2d.setColor(Color.GRAY);
g2d.fillPolygon(x, y, 5);
// 绘制内容
g2d.setColor(Color.WHITE);
g2d.setFont(font);
g2d.drawString(content, x[4], y[4] - fontHeight/4);
}
/**
* @Description: 边界检查X
*
* @Author: ws
* @Date: 2025/11/19 11:20
**/
private double initBoundaryX(double x) {
if (x <= 0) {
x = radius;
}else if (x >= imageWidth) {
x = imageWidth - radius;
}
return x;
}
/**
* @Description: 边界检查Y
*
* @Author: ws
* @Date: 2025/11/19 11:20
**/
private double initBoundaryY(double y) {
if (y <= 0) {
y = radius;
}else if (y >= imageHeight) {
y = imageHeight - radius;
}
return y;
}
/**
* @Description: 获取文字高度
*
* @Author: ws
* @Date: 2025/11/19 11:20
**/
public int getWordWidth(String content) {
int width = 0;
for (int i = 0; i < content.length(); i++) {
width += fontMetrics.charWidth(content.charAt(i));
}
return width;
}
}
大家有兴趣可以完善一下碰撞检查。