|
|
@ -0,0 +1,281 @@ |
|
|
|
|
|
package com.inspect.simulator.service; |
|
|
|
|
|
|
|
|
|
|
|
import com.inspect.simulator.domain.Infrared.Coordinate; |
|
|
|
|
|
import com.inspect.simulator.domain.Infrared.InfraredInfo; |
|
|
|
|
|
import com.inspect.simulator.utils.ThermalGenerator; |
|
|
|
|
|
import lombok.extern.slf4j.Slf4j; |
|
|
|
|
|
import org.springframework.core.io.ClassPathResource; |
|
|
|
|
|
import org.springframework.stereotype.Service; |
|
|
|
|
|
|
|
|
|
|
|
import java.awt.*; |
|
|
|
|
|
import java.awt.image.BufferedImage; |
|
|
|
|
|
import java.io.InputStream; |
|
|
|
|
|
import java.util.ArrayList; |
|
|
|
|
|
import java.util.List; |
|
|
|
|
|
|
|
|
|
|
|
@Slf4j |
|
|
|
|
|
@Service |
|
|
|
|
|
public class ImageAnnotationService { |
|
|
|
|
|
/** |
|
|
|
|
|
* 字体文件路径 |
|
|
|
|
|
*/ |
|
|
|
|
|
private static final String FONT_PATH = "/fonts/NotoSansSC-Regular.ttf"; |
|
|
|
|
|
/** |
|
|
|
|
|
* 绘制十字的空心圆半径和外长 |
|
|
|
|
|
*/ |
|
|
|
|
|
private static final int CROSSHAIR_R = 6, CROSSHAIR_ARM = 6; |
|
|
|
|
|
private Font chineseFont; |
|
|
|
|
|
|
|
|
|
|
|
private static int clamp(int v, int lo, int hi) { |
|
|
|
|
|
return Math.max(lo, Math.min(hi, v)); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 是否生成伪彩色热图 |
|
|
|
|
|
*/ |
|
|
|
|
|
public static boolean isGenThermal(String imagePath) { |
|
|
|
|
|
// 继保机器狗 |
|
|
|
|
|
String[] keywords = {"R100-001"}; |
|
|
|
|
|
for (String key : keywords) { |
|
|
|
|
|
String flag = "_" + key + "_"; |
|
|
|
|
|
|
|
|
|
|
|
if (imagePath.contains(flag)) { |
|
|
|
|
|
return true; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
return false; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* @param originalImage 原图 |
|
|
|
|
|
* @param infraredInfo 热成像信息 |
|
|
|
|
|
* @param coordinates 画框坐标 |
|
|
|
|
|
*/ |
|
|
|
|
|
public BufferedImage annotate(BufferedImage originalImage, InfraredInfo infraredInfo, List<Coordinate> coordinates, boolean genThermal) { |
|
|
|
|
|
// 转为标注数据 |
|
|
|
|
|
TemperatureDataService tempData = new TemperatureDataService(); |
|
|
|
|
|
tempData.load(infraredInfo.getTemperatureMatrix()); |
|
|
|
|
|
|
|
|
|
|
|
infraredInfo.setMaxTemp(tempData.getMaxTemp()); |
|
|
|
|
|
infraredInfo.setMinTemp(tempData.getMinTemp()); |
|
|
|
|
|
|
|
|
|
|
|
// 插值比例(红外测温分辨率和可见光图片分辨率可能存在不一致情况) |
|
|
|
|
|
float scaleX = (float) originalImage.getWidth() / tempData.getCols(); |
|
|
|
|
|
float scaleY = (float) originalImage.getHeight() / tempData.getRows(); |
|
|
|
|
|
|
|
|
|
|
|
int outW = originalImage.getWidth(); |
|
|
|
|
|
int outH = originalImage.getHeight(); |
|
|
|
|
|
|
|
|
|
|
|
BufferedImage canvas = new BufferedImage(outW, outH, BufferedImage.TYPE_INT_RGB); |
|
|
|
|
|
Graphics2D g = canvas.createGraphics(); |
|
|
|
|
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); |
|
|
|
|
|
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); |
|
|
|
|
|
|
|
|
|
|
|
BufferedImage canvasImage; |
|
|
|
|
|
if (genThermal) { |
|
|
|
|
|
canvasImage = ThermalGenerator.generate(tempData, outW, outH); |
|
|
|
|
|
} else { |
|
|
|
|
|
canvasImage = originalImage; |
|
|
|
|
|
} |
|
|
|
|
|
g.drawImage(canvasImage, 0, 0, null); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 加载字体 |
|
|
|
|
|
ensureFont(); |
|
|
|
|
|
int fontSize = Math.max(16, originalImage.getWidth() / 70); |
|
|
|
|
|
g.setFont(chineseFont.deriveFont(Font.PLAIN, fontSize)); |
|
|
|
|
|
FontMetrics fm = g.getFontMetrics(); |
|
|
|
|
|
|
|
|
|
|
|
List<TextLabel> labels = new ArrayList<>(); |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 标注全图最高温和最低温 |
|
|
|
|
|
*/ |
|
|
|
|
|
// 1.转换全图最高温和最低温的点坐标 |
|
|
|
|
|
int maxX = Math.round(tempData.getMaxCol() * scaleX) + 1; |
|
|
|
|
|
int maxY = Math.round(tempData.getMaxRow() * scaleY) + 1; |
|
|
|
|
|
int minX = Math.round(tempData.getMinCol() * scaleX) + 1; |
|
|
|
|
|
int minY = Math.round(tempData.getMinRow() * scaleY) + 1; |
|
|
|
|
|
|
|
|
|
|
|
// 2.根据坐标绘制十字图形 |
|
|
|
|
|
drawCrosshair(g, maxX, maxY, new Color(255, 23, 68)); |
|
|
|
|
|
drawCrosshair(g, minX, minY, new Color(41, 121, 255)); |
|
|
|
|
|
|
|
|
|
|
|
// 3.根据坐标绘制文本,并对十字进行避障展示 |
|
|
|
|
|
int r = CROSSHAIR_R; |
|
|
|
|
|
int arm = CROSSHAIR_ARM; |
|
|
|
|
|
Rectangle maxCrosshairRect = new Rectangle(maxX - r - arm, maxY - r - arm, (r + arm) * 2, (r + arm) * 2); |
|
|
|
|
|
Rectangle minCrosshairRect = new Rectangle(minX - r - arm, minY - r - arm, (r + arm) * 2, (r + arm) * 2); |
|
|
|
|
|
String maxText = String.format("最高温度: %.1f", tempData.getMaxTemp()); |
|
|
|
|
|
String minText = String.format("最低温度: %.1f", tempData.getMinTemp()); |
|
|
|
|
|
labels.add(createLabel(maxText, maxX, maxY, fm, true, maxCrosshairRect)); |
|
|
|
|
|
labels.add(createLabel(minText, minX, minY, fm, true, minCrosshairRect)); |
|
|
|
|
|
|
|
|
|
|
|
log.info("最高温度: {}, 表格坐标: ({},{}), 图片坐标: ({},{})", tempData.getMaxTemp(), tempData.getMaxCol(), tempData.getMaxRow(), maxX, maxY); |
|
|
|
|
|
log.info("最低温度: {}, 表格坐标: ({},{}), 图片坐标: ({},{})", tempData.getMinTemp(), tempData.getMinCol(), tempData.getMinRow(), minX, minY); |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 画框并标注内部最高温、最低温、平均温 |
|
|
|
|
|
* TODO: 后续可拓展为多组画框,需前端支持 |
|
|
|
|
|
*/ |
|
|
|
|
|
if (coordinates.size() > 0) { |
|
|
|
|
|
// 暂时只标注第一组坐标 |
|
|
|
|
|
Coordinate coordinate = coordinates.get(0); |
|
|
|
|
|
|
|
|
|
|
|
int fx = coordinate.getFirstX(); |
|
|
|
|
|
int fy = coordinate.getFirstY(); |
|
|
|
|
|
int sx = coordinate.getSecondX(); |
|
|
|
|
|
int sy = coordinate.getSecondY(); |
|
|
|
|
|
int w = coordinate.getWidth(); |
|
|
|
|
|
int h = coordinate.getHeight(); |
|
|
|
|
|
|
|
|
|
|
|
float rateX = (float) outW / w; |
|
|
|
|
|
float rateY = (float) outH / h; |
|
|
|
|
|
int x1 = Math.round(fx * rateX); |
|
|
|
|
|
int y1 = Math.round(fy * rateY); |
|
|
|
|
|
int x2 = Math.round(sx * rateX); |
|
|
|
|
|
int y2 = Math.round(sy * rateY); |
|
|
|
|
|
|
|
|
|
|
|
int rx1 = Math.min(x1, x2), ry1 = Math.min(y1, y2); |
|
|
|
|
|
int rx2 = Math.max(x1, x2), ry2 = Math.max(y1, y2); |
|
|
|
|
|
|
|
|
|
|
|
int csvCol1 = Math.round(rx1 / scaleX), csvCol2 = Math.round((rx2 + scaleX - 1) / scaleX); |
|
|
|
|
|
int csvRow1 = Math.round(ry1 / scaleY), csvRow2 = Math.round((ry2 + scaleY - 1) / scaleY); |
|
|
|
|
|
|
|
|
|
|
|
// 统计框内温度 |
|
|
|
|
|
TemperatureDataService.RegionStats stats = tempData.calcRegion(csvRow1, csvRow2, csvCol1, csvCol2); |
|
|
|
|
|
|
|
|
|
|
|
g.setColor(Color.white); |
|
|
|
|
|
g.setStroke(new BasicStroke(3)); |
|
|
|
|
|
g.drawRect(rx1, ry1, rx2 - rx1, ry2 - ry1); |
|
|
|
|
|
|
|
|
|
|
|
String boxText = String.format("最高温度: %.1f 最低温度: %.1f 平均温度: %.1f", stats.getMax(), stats.getMin(), stats.getAvg()); |
|
|
|
|
|
|
|
|
|
|
|
// 用于文本避障 |
|
|
|
|
|
Rectangle occupied = new Rectangle(rx1, ry1, rx2 - rx1, ry2 - ry1); |
|
|
|
|
|
|
|
|
|
|
|
labels.add(createLabel(boxText, rx1, ry1 - 5, fm, true, occupied)); |
|
|
|
|
|
|
|
|
|
|
|
infraredInfo.setFrameMax(stats.getMax()); |
|
|
|
|
|
infraredInfo.setFrameMin(stats.getMin()); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
drawLabels(g, labels, canvas.getWidth(), canvas.getHeight()); |
|
|
|
|
|
|
|
|
|
|
|
g.dispose(); |
|
|
|
|
|
|
|
|
|
|
|
return canvas; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private TextLabel createLabel(String text, int anchorX, int anchorY, FontMetrics fm, boolean alignLeft, Rectangle occupied) { |
|
|
|
|
|
int textWidth = fm.stringWidth(text); |
|
|
|
|
|
int textHeight = fm.getHeight(); |
|
|
|
|
|
float ascent = fm.getAscent(); |
|
|
|
|
|
float descent = fm.getDescent(); |
|
|
|
|
|
return new TextLabel(text, anchorX, anchorY, textWidth, textHeight, ascent, descent, alignLeft, occupied); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 绘制文本和背景蒙层,避免与图形重合 |
|
|
|
|
|
*/ |
|
|
|
|
|
private void drawLabels(Graphics2D g, List<TextLabel> labels, int imgW, int imgH) { |
|
|
|
|
|
for (TextLabel label : labels) { |
|
|
|
|
|
int textW = label.width; |
|
|
|
|
|
int textH = label.height; |
|
|
|
|
|
int padX = 2, padY = 1; |
|
|
|
|
|
int marginY = 4; |
|
|
|
|
|
|
|
|
|
|
|
int bgW = textW + padX * 2; |
|
|
|
|
|
int bgH = textH + padY * 2; |
|
|
|
|
|
int bgX = label.alignLeft ? clamp(label.anchorX - padX, 0, imgW - bgW) : clamp(label.anchorX - textW / 2 - padX, 0, imgW - bgW); |
|
|
|
|
|
int bgY = label.anchorY - textH / 2 - padY; |
|
|
|
|
|
|
|
|
|
|
|
// 文本与图形避免重合 |
|
|
|
|
|
// 文本优先展示位置:图形上方 > 图形下方 > 图形中 |
|
|
|
|
|
java.awt.Rectangle occupied = label.occupied; |
|
|
|
|
|
if (false && occupied != null) { |
|
|
|
|
|
if (occupied.y - bgH - marginY >= 0) { |
|
|
|
|
|
bgY = occupied.y - bgH - marginY; |
|
|
|
|
|
} else if (occupied.y + occupied.height + bgH + marginY <= imgH) { |
|
|
|
|
|
bgY = occupied.y + occupied.height + marginY; |
|
|
|
|
|
} else { |
|
|
|
|
|
bgY = occupied.y + marginY; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
g.setColor(new Color(0, 0, 0, 128)); |
|
|
|
|
|
g.fillRoundRect(bgX, bgY, bgW, bgH, 0, 0); |
|
|
|
|
|
|
|
|
|
|
|
// 文本在背景蒙层中居中 |
|
|
|
|
|
int baseline = bgY + (bgH - textH) / 2 + Math.round(label.ascent); |
|
|
|
|
|
|
|
|
|
|
|
g.setColor(Color.WHITE); |
|
|
|
|
|
g.drawString(label.text, bgX + padX, baseline); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 绘制内部为空心圆的十字 |
|
|
|
|
|
*/ |
|
|
|
|
|
private void drawCrosshair(Graphics2D g, int x, int y, Color color) { |
|
|
|
|
|
int r = CROSSHAIR_R; |
|
|
|
|
|
int arm = CROSSHAIR_ARM; |
|
|
|
|
|
g.setColor(color); |
|
|
|
|
|
g.setStroke(new BasicStroke(3)); |
|
|
|
|
|
g.drawLine(x - r - arm, y, x - r, y); |
|
|
|
|
|
g.drawLine(x + r, y, x + r + arm, y); |
|
|
|
|
|
g.drawLine(x, y - r - arm, x, y - r); |
|
|
|
|
|
g.drawLine(x, y + r, x, y + r + arm); |
|
|
|
|
|
g.drawOval(x - r, y - r, r * 2, r * 2); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 加载字体文件 |
|
|
|
|
|
*/ |
|
|
|
|
|
private void ensureFont() { |
|
|
|
|
|
if (chineseFont != null) { |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
// 首先尝试从类路径加载 |
|
|
|
|
|
try { |
|
|
|
|
|
InputStream is = new ClassPathResource(FONT_PATH).getInputStream(); |
|
|
|
|
|
chineseFont = Font.createFont(Font.TRUETYPE_FONT, is); |
|
|
|
|
|
is.close(); |
|
|
|
|
|
return; |
|
|
|
|
|
} catch (Exception ignored) { |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 备用方案:尝试使用系统常用的中文字体 |
|
|
|
|
|
String[] candidates = {"Microsoft YaHei", "SimHei", "SimSun", "WenQuanYi Micro Hei", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC"}; |
|
|
|
|
|
for (String name : candidates) { |
|
|
|
|
|
Font f = new Font(name, Font.PLAIN, 14); |
|
|
|
|
|
if (f.canDisplayUpTo("温度") == -1) { |
|
|
|
|
|
chineseFont = f; |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
// 兜底方案:使用默认字体(中文字符将显示为方框) |
|
|
|
|
|
chineseFont = new Font(Font.SANS_SERIF, Font.PLAIN, 14); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
static class TextLabel { |
|
|
|
|
|
String text; |
|
|
|
|
|
int anchorX, anchorY; |
|
|
|
|
|
int width, height; |
|
|
|
|
|
float ascent, descent; |
|
|
|
|
|
boolean alignLeft; // true = 文本左对齐, false = 文本居中对齐 |
|
|
|
|
|
Rectangle occupied; // 文字需要避开该区域展示 |
|
|
|
|
|
|
|
|
|
|
|
TextLabel(String text, int ax, int ay, int w, int h, float asc, float desc, boolean al, Rectangle oc) { |
|
|
|
|
|
this.text = text; |
|
|
|
|
|
anchorX = ax; |
|
|
|
|
|
anchorY = ay; |
|
|
|
|
|
width = w; |
|
|
|
|
|
height = h; |
|
|
|
|
|
ascent = asc; |
|
|
|
|
|
descent = desc; |
|
|
|
|
|
alignLeft = al; |
|
|
|
|
|
occupied = oc; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |