Browse Source

feat: 1.完善红外DL/T664格式红外图片的解析流程;2.整合画框的流程;3.引入字体文件解决服务器字体文件缺失的问题;4.根据红外温度矩阵生成伪热力图

master
yinhuaiwei 4 weeks ago
parent
commit
c94928049a
10 changed files with 666 additions and 870 deletions
  1. +3
    -0
      src/main/java/com/inspect/simulator/constant/AlgConstants.java
  2. +0
    -4
      src/main/java/com/inspect/simulator/controller/ModelController.java
  3. +15
    -13
      src/main/java/com/inspect/simulator/domain/Infrared/Coordinate.java
  4. +281
    -0
      src/main/java/com/inspect/simulator/service/ImageAnnotationService.java
  5. +87
    -0
      src/main/java/com/inspect/simulator/service/TemperatureDataService.java
  6. +6
    -2
      src/main/java/com/inspect/simulator/service/impl/AlgorithmServiceImpl.java
  7. +126
    -851
      src/main/java/com/inspect/simulator/service/impl/HikVisionServiceImpl.java
  8. +84
    -0
      src/main/java/com/inspect/simulator/utils/ThermalGenerator.java
  9. +64
    -0
      src/main/java/com/inspect/simulator/utils/ThermalPalette.java
  10. BIN
      src/main/resources/fonts/NotoSansSC-Regular.ttf

+ 3
- 0
src/main/java/com/inspect/simulator/constant/AlgConstants.java View File

@ -3,8 +3,11 @@ package com.inspect.simulator.constant;
public class AlgConstants { public class AlgConstants {
public static final String METER = "meter"; public static final String METER = "meter";
public static final String INFRA_1800 = "infra_1800"; public static final String INFRA_1800 = "infra_1800";
/** 华软无人机的红外 **/
public static final String INFRA_YU3 = "infra_yu3"; public static final String INFRA_YU3 = "infra_yu3";
/** DLT664协议的红外 **/
public static final String INFRA_CAMERA = "infra_camera"; public static final String INFRA_CAMERA = "infra_camera";
/** 海康/大华摄像机的红外析 **/
public static final String INFRA_CAMERA_REVERSE = "infra_camera_reverse"; public static final String INFRA_CAMERA_REVERSE = "infra_camera_reverse";
public static final String XB = "xb"; public static final String XB = "xb";


+ 0
- 4
src/main/java/com/inspect/simulator/controller/ModelController.java View File

@ -1,14 +1,10 @@
package com.inspect.simulator.controller; package com.inspect.simulator.controller;
import com.inspect.simulator.domain.AjaxResult; import com.inspect.simulator.domain.AjaxResult;
import com.inspect.simulator.domain.visual.ApiVisualRequest;
import com.inspect.simulator.domain.visual.ImageData; import com.inspect.simulator.domain.visual.ImageData;
import com.inspect.simulator.service.ModelService; import com.inspect.simulator.service.ModelService;
import lombok.extern.log4j.Log4j;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import sun.rmi.runtime.Log;
import javax.annotation.Resource; import javax.annotation.Resource;


+ 15
- 13
src/main/java/com/inspect/simulator/domain/Infrared/Coordinate.java View File

@ -14,25 +14,27 @@ public class Coordinate {
private Integer secondX; private Integer secondX;
private Integer secondY; private Integer secondY;
private String maxValue;
private String maxValue;
private String minValue;
private String minValue;
private String avgValue;
private String avgValue;
private String boxName;
private String boxName;
private String width;
private Integer width;
private String height;
private Integer height;
private String maxValueX;
private String maxValueY;
private String maxValueX;
private String maxValueY;
public Coordinate(int firstX, int firstY, int secondX, int secondY) {
this.firstX=firstX;
this.firstY=firstY;
this.secondX=secondX;
this.secondY=secondY;
public Coordinate(int firstX, int firstY, int secondX, int secondY, int width, int height) {
this.firstX = firstX;
this.firstY = firstY;
this.secondX = secondX;
this.secondY = secondY;
this.width = width;
this.height = height;
} }
} }

+ 281
- 0
src/main/java/com/inspect/simulator/service/ImageAnnotationService.java View File

@ -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;
}
}
}

+ 87
- 0
src/main/java/com/inspect/simulator/service/TemperatureDataService.java View File

@ -0,0 +1,87 @@
package com.inspect.simulator.service;
import lombok.Data;
import lombok.Getter;
@Data
public class TemperatureDataService {
private float[][] temperatures; // [row][col] = [y][x]
private int rows;
private int cols;
private int minRow, minCol;
private int maxRow, maxCol;
private float minTemp = Float.MAX_VALUE;
private float maxTemp = Float.MIN_VALUE;
@Override
public String toString() {
return "TemperatureDataService{" + "rows=" + rows + ", cols=" + cols + ", minRow=" + minRow + ", minCol=" + minCol + ", maxRow=" + maxRow + ", maxCol=" + maxCol + ", minTemp=" + minTemp + ", maxTemp=" + maxTemp + '}';
}
public void load(float[][] temperatureMatrix) {
temperatures = temperatureMatrix;
rows = temperatures.length;
cols = temperatures[0].length;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
float v = temperatures[r][c];
if (!Float.isNaN(v)) {
if (v < minTemp) {
minTemp = v;
minRow = r;
minCol = c;
}
if (v > maxTemp) {
maxTemp = v;
maxRow = r;
maxCol = c;
}
}
}
}
}
public RegionStats calcRegion(int rowStart, int rowEnd, int colStart, int colEnd) {
float min = Float.MAX_VALUE, max = Float.MIN_VALUE;
double sum = 0;
int count = 0;
int r0 = Math.max(0, rowStart), r1 = Math.min(rows, rowEnd);
int c0 = Math.max(0, colStart), c1 = Math.min(cols, colEnd);
for (int r = r0; r < r1; r++) {
for (int c = c0; c < c1; c++) {
float v = temperatures[r][c];
if (!Float.isNaN(v)) {
if (v < min) {
min = v;
}
if (v > max) {
max = v;
}
sum += v;
count++;
}
}
}
if (count == 0) {
return new RegionStats(0, 0, 0);
}
return new RegionStats(min, max, sum / count);
}
@Getter
public static class RegionStats {
private final float min;
private final float max;
private final double avg;
public RegionStats(float min, float max, double avg) {
this.min = min;
this.max = max;
this.avg = avg;
}
}
}

+ 6
- 2
src/main/java/com/inspect/simulator/service/impl/AlgorithmServiceImpl.java View File

@ -279,15 +279,19 @@ public class AlgorithmServiceImpl implements AlgorithmService {
// analyseResPoint.setConf(String.format("%.2f", (double) infraredInfo.getFrameMax())); // analyseResPoint.setConf(String.format("%.2f", (double) infraredInfo.getFrameMax()));
analyseResPoint.setResImageUrl(infraredInfo.getOutPath()); analyseResPoint.setResImageUrl(infraredInfo.getOutPath());
analyseResPoint.setType(typeListStr); analyseResPoint.setType(typeListStr);
if (infraredInfo.getFrameMax() == -1 && infraredInfo.getFrameMin() == -1) {
float value = infraredInfo.getFrameMax() == 0.0f ? infraredInfo.getMaxTemp() : infraredInfo.getFrameMax();
if (value == 0.0f) {
analyseResPoint.setCode("2002"); analyseResPoint.setCode("2002");
analyseResPoint.setValue("--"); analyseResPoint.setValue("--");
analyseResPoint.setDesc("分析失败"); analyseResPoint.setDesc("分析失败");
} else { } else {
analyseResPoint.setCode("2000"); analyseResPoint.setCode("2000");
analyseResPoint.setDesc("正常"); analyseResPoint.setDesc("正常");
analyseResPoint.setValue(String.format("%.2f", (double) infraredInfo.getFrameMax()));
analyseResPoint.setValue(String.format("%.1f", value));
} }
if (StringUtils.isNotBlank(analyseResPoint.getImageNormalUrlPath())) { if (StringUtils.isNotBlank(analyseResPoint.getImageNormalUrlPath())) {
String imageNormalUrlPath = resultAnalysisMapper.selectImg(patrolPointId); String imageNormalUrlPath = resultAnalysisMapper.selectImg(patrolPointId);
if (StringUtils.isBlank(imageNormalUrlPath)) { if (StringUtils.isBlank(imageNormalUrlPath)) {


+ 126
- 851
src/main/java/com/inspect/simulator/service/impl/HikVisionServiceImpl.java
File diff suppressed because it is too large
View File


+ 84
- 0
src/main/java/com/inspect/simulator/utils/ThermalGenerator.java View File

@ -0,0 +1,84 @@
package com.inspect.simulator.utils;
import com.inspect.simulator.service.TemperatureDataService;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import javax.imageio.ImageIO;
public class ThermalGenerator {
private ThermalGenerator() {}
/**
* 生成伪彩色热图
*
* @param temps 温度矩阵 [row][col]
* @param tMin 全图最低温
* @param tMax 全图最高温
* @param outW 输出宽度
* @param outH 输出高度
* @param savePath 保存路径 (null 则不保存)
* @return BufferedImage
*/
public static BufferedImage generate(
float[][] temps, float tMin, float tMax, int outW, int outH, String savePath)
throws IOException {
int irH = temps.length, irW = temps[0].length;
double sx = (double) irW / outW;
double sy = (double) irH / outH;
BufferedImage img = new BufferedImage(outW, outH, BufferedImage.TYPE_INT_RGB);
for (int y = 0; y < outH; y++) {
for (int x = 0; x < outW; x++) {
float temp = bilinear(temps, irW, irH, x * sx, y * sy);
img.setRGB(x, y, ThermalPalette.map(temp, tMin, tMax));
}
}
if (savePath != null) {
File f = new File(savePath);
File p = f.getParentFile();
if (p != null && !p.exists()) {
p.mkdirs();
}
ImageIO.write(img, "JPEG", f);
}
return img;
}
public static BufferedImage generate(TemperatureDataService tempData, int outW, int outH) {
float[][] temps = tempData.getTemperatures();
float tempMax = tempData.getMaxTemp();
float tempMin = tempData.getMinTemp();
int irH = temps.length, irW = temps[0].length;
double sx = (double) irW / outW;
double sy = (double) irH / outH;
BufferedImage img = new BufferedImage(outW, outH, BufferedImage.TYPE_INT_RGB);
for (int y = 0; y < outH; y++) {
for (int x = 0; x < outW; x++) {
float temp = bilinear(temps, irW, irH, x * sx, y * sy);
img.setRGB(x, y, ThermalPalette.map(temp, tempMin, tempMax));
}
}
return img;
}
/** 双线性插值 */
private static float bilinear(float[][] temps, int w, int h, double x, double y) {
int x0 = (int) Math.floor(x), y0 = (int) Math.floor(y);
int x1 = Math.min(x0 + 1, w - 1), y1 = Math.min(y0 + 1, h - 1);
x0 = Math.max(0, x0);
y0 = Math.max(0, y0);
double fx = x - x0, fy = y - y0;
return (float)
(temps[y0][x0] * (1 - fx) * (1 - fy)
+ temps[y0][x1] * fx * (1 - fy)
+ temps[y1][x0] * (1 - fx) * fy
+ temps[y1][x1] * fx * fy);
}
}

+ 64
- 0
src/main/java/com/inspect/simulator/utils/ThermalPalette.java View File

@ -0,0 +1,64 @@
package com.inspect.simulator.utils;
/**
* 根据红外图片生成伪热力图
*/
public final class ThermalPalette {
// 控制点 (归一化位置 0~1, R, G, B)
private static final float[][] PTS = {
{ 0.000f, 0, 0, 0 }, // 纯黑
{ 0.082f, 50, 16, 51 }, // 深紫黑
{ 0.123f, 21, 16, 55 }, // 深靛蓝
{ 0.178f, 20, 15, 93 }, //
{ 0.219f, 37, 16, 116 }, // 亮蓝
{ 0.260f, 62, 18, 123 }, // 蓝紫
{ 0.315f, 102, 28, 133 }, // 紫色
{ 0.356f, 125, 22, 135 }, // 紫品红
{ 0.397f, 150, 19, 138 }, // 品红
{ 0.438f, 174, 30, 135 }, // 品红偏红
{ 0.466f, 184, 37, 125 }, // 红品红
{ 0.507f, 192, 59, 112 }, // 红色
{ 0.548f, 208, 65, 68 }, // 红橙
{ 0.575f, 212, 73, 42 }, // 橙红
{ 0.616f, 213, 93, 28 }, // 橙色
{ 0.657f, 219, 119, 33 }, // 橙黄
{ 0.712f, 215, 131, 30 }, // 黄色
{ 0.753f, 202, 139, 37 }, //
{ 0.795f, 215, 167, 36 }, // 亮黄
{ 0.836f, 211, 178, 40 }, // 黄白
{ 0.877f, 196, 173, 80 }, // 浅黄
{ 0.918f, 227, 216, 147 }, // 极浅黄
{ 1.000f, 255, 255, 255 }, // 纯白
};
private ThermalPalette() {}
/**
* 将温度值映射为 RGB 颜色
* @param temp 温度值 (单位: °C)
* @param tMin 全图最低温
* @param tMax 全图最高温
* @return 0xRRGGBB
*/
public static int map(float temp, float tMin, float tMax) {
float t = (temp - tMin) / (tMax - tMin);
t = Math.max(0, Math.min(1, t));
for (int i = 0; i < PTS.length - 1; i++) {
float p0 = PTS[i][0], p1 = PTS[i + 1][0];
if (t >= p0 && t <= p1) {
float lt = (t - p0) / (p1 - p0);
int r = lerp(PTS[i][1], PTS[i + 1][1], lt);
int g = lerp(PTS[i][2], PTS[i + 1][2], lt);
int b = lerp(PTS[i][3], PTS[i + 1][3], lt);
return (r << 16) | (g << 8) | b;
}
}
return 0xFFFFFF;
}
private static int lerp(float a, float b, float t) {
return Math.round(a + (b - a) * t);
}
}

BIN
src/main/resources/fonts/NotoSansSC-Regular.ttf View File


Loading…
Cancel
Save