摸鱼笔记[11]-使用quickjs设计绘图指令绘制机器视觉结果图
摘要
在 Java 桌面应用中集成 QuickJS 脚本引擎,设计一套面向工业机器视觉的绘图指令集,让用户通过 JavaScript 脚本将测量结果(宽度、高度、对称度等)实时绘制在相机图像上,实现四宫格多路画面的可视化监控。
声明
本文内容由 AI 辅助生成, 已经人工审核和编辑。
简介
Cognex 智能相机的绘图函数
Cognex(康耐视)是全球领先的机器视觉公司,其 Insight 系列智能相机在工业检测领域广泛应用。Insight 相机内置 EasyBuilder 界面和电子表格环境,用户可以通过脚本对检测结果进行图形化标注。
Cognex 智能相机的绘图函数设计理念非常简洁——面向过程的绘图指令:
| 函数 | 功能 |
|---|---|
PlotString |
在指定像素坐标绘制文字 |
PlotLine |
绘制线段 |
PlotCircle |
绘制圆(描边) |
PlotPolygon |
绘制多边形(描边) |
PlotPoint |
绘制实心点 |
这些函数的共同特点是:
- 前两个参数固定为图像尺寸(宽、高),用于建立坐标映射
- 使用左手坐标系:原点位于图像左上角,X 向右增长,Y 向下增长
- 像素级坐标:所有坐标值都是基于原始图像分辨率的像素值
- RGB 颜色:支持整数(如
0xFF0000)或十六进制字符串(如"#FF0000")
这种设计使得脚本编写者无需关心屏幕显示尺寸和缩放比例,只需要按照图像原始分辨率来指定坐标即可,渲染层会自动处理坐标变换。
面向过程的工业控制脚本思想
工业控制场景下的脚本语言设计与通用编程有显著不同,核心思想是面向过程、指令驱动:
- 无状态执行:每次收到消息,脚本从头执行一遍绘图指令,不维护复杂的 UI 状态
- 指令即绘制:每个绘图函数调用就是一条绘图指令,引擎收集所有指令后统一渲染
- 声明式布局:脚本只描述"画什么、在哪里画",不关心"怎么画"
- 快速迭代:现场工程师可以修改脚本后立即看到效果,无需重新编译主程序
这种设计模式在工业软件中非常实用——它把"业务逻辑"(测量数据的解析和计算)和"渲染逻辑"(如何在屏幕上绘制)解耦,让非专业程序员也能快速上手。
工程
项目概述
CameraViewerAutoWeld 是一个基于 Java + Swing/FlatLaf + MQTT + QuickJS 的相机/测量数据可视化桌面应用。主界面为 2×2 四宫格,每格独立显示一路画面,通过 MQTT 订阅图像、测量数据、日志三类消息,并用 JavaScript 脚本解析数据、调用绘图函数渲染结果。
技术栈:
| 组件 | 选型 |
|---|---|
| 语言 | Java 25 |
| 构建 | Gradle 9.7.1(离线模式) |
| UI | FlatLaf 3.4.1 + Swing |
| MQTT | Eclipse Paho 1.2.5 |
| JS 引擎 | QuickJS(cn.net.zhijian.quickjs-0.2.2) |
| 脚本编辑器 | RSyntaxTextArea 3.3.4 |
| JSON | Gson 2.10.1 |
核心原理
1. 指令模式(Command Pattern)—— 绘图指令的抽象
整个绘图系统的核心是指令模式。每条绘图指令都是一个实现了 DrawingCommand 接口的对象:
public interface DrawingCommand {
void draw(Graphics2D g2d, AffineTransform imageToScreen);
}
imageToScreen 参数是一个仿射变换(AffineTransform),负责将图像坐标系中的点映射到屏幕坐标系。这样设计的优势是:脚本使用图像原始分辨率(如 9344×7000)来指定坐标,而渲染时自动适配控件的显示尺寸。
五种绘图指令的实现:
线段指令(DrawLineCommand.java):
public class DrawLineCommand implements DrawingCommand {
private final double x1, y1, x2, y2;
private final float thickness;
private final Color color;
@Override
public void draw(Graphics2D g2d, AffineTransform imageToScreen) {
Point2D p1 = CoordinateMapper.mapPoint(imageToScreen, x1, y1);
Point2D p2 = CoordinateMapper.mapPoint(imageToScreen, x2, y2);
float width = Math.max(1.0f, (float) CoordinateMapper.mapLength(imageToScreen, thickness));
g2d.setColor(color);
g2d.setStroke(new BasicStroke(width));
g2d.draw(new Line2D.Double(p1, p2));
}
}
圆指令(DrawCircleCommand.java):
public class DrawCircleCommand implements DrawingCommand {
private final double centerX, centerY, diameter;
private final float thickness;
private final Color color;
@Override
public void draw(Graphics2D g2d, AffineTransform imageToScreen) {
Point2D p = CoordinateMapper.mapPoint(imageToScreen, centerX, centerY);
double d = CoordinateMapper.mapLength(imageToScreen, diameter);
float width = Math.max(1.0f, (float) CoordinateMapper.mapLength(imageToScreen, thickness));
g2d.setColor(color);
g2d.setStroke(new BasicStroke(width));
g2d.draw(new Ellipse2D.Double(p.getX() - d / 2, p.getY() - d / 2, d, d));
}
}
文字指令(DrawStringCommand.java):
public class DrawStringCommand implements DrawingCommand {
private final double x, y;
private final String text;
private final int fontSize;
private final Color color;
@Override
public void draw(Graphics2D g2d, AffineTransform imageToScreen) {
Point2D p = CoordinateMapper.mapPoint(imageToScreen, x, y);
g2d.setColor(color);
int size = Math.max(8, (int) Math.round(CoordinateMapper.mapLength(imageToScreen, fontSize)));
g2d.setFont(new Font(Font.MONOSPACED, Font.PLAIN, size));
g2d.drawString(text != null ? text : "", (int) p.getX(), (int) p.getY());
}
}
多边形指令(DrawPolygonCommand.java):
public class DrawPolygonCommand implements DrawingCommand {
private final double[] points; // [x0, y0, x1, y1, ...]
private final float thickness;
private final Color color;
@Override
public void draw(Graphics2D g2d, AffineTransform imageToScreen) {
if (points == null || points.length < 4 || points.length % 2 != 0) {
return;
}
int n = points.length / 2;
int[] xs = new int[n];
int[] ys = new int[n];
for (int i = 0; i < n; i++) {
Point2D p = CoordinateMapper.mapPoint(imageToScreen, points[i * 2], points[i * 2 + 1]);
xs[i] = (int) Math.round(p.getX());
ys[i] = (int) Math.round(p.getY());
}
float width = Math.max(1.0f, (float) CoordinateMapper.mapLength(imageToScreen, thickness));
g2d.setColor(color);
g2d.setStroke(new BasicStroke(width));
g2d.drawPolygon(xs, ys, n);
}
}
点指令(DrawPointCommand.java):
public class DrawPointCommand implements DrawingCommand {
private final double x, y;
private final float thickness;
private final Color color;
@Override
public void draw(Graphics2D g2d, AffineTransform imageToScreen) {
Point2D p = CoordinateMapper.mapPoint(imageToScreen, x, y);
double size = Math.max(2.0, CoordinateMapper.mapLength(imageToScreen, thickness));
g2d.setColor(color);
g2d.fill(new Ellipse2D.Double(p.getX() - size / 2, p.getY() - size / 2, size, size));
}
}
2. 坐标映射 —— 图像坐标到屏幕坐标的自动适配
CoordinateMapper 是坐标变换的工具类,核心思想是使用仿射变换的缩放因子来同步缩放线宽、字号等长度属性:
public final class CoordinateMapper {
// 获取变换的缩放因子(行列式开方)
public static double getScale(AffineTransform transform) {
return Math.sqrt(Math.abs(transform.getDeterminant()));
}
// 点映射:图像坐标 → 屏幕坐标
public static Point2D mapPoint(AffineTransform transform, double imageX, double imageY) {
return transform.transform(new Point2D.Double(imageX, imageY), null);
}
// 长度映射:线宽、直径等随缩放同步调整
public static double mapLength(AffineTransform transform, double imageLength) {
return imageLength * getScale(transform);
}
// 颜色解析:支持整数和十六进制字符串
public static Color parseColor(Object colorObj) {
if (colorObj instanceof Number) {
int rgb = ((Number) colorObj).intValue();
return new Color(rgb);
}
if (colorObj instanceof String) {
String s = ((String) colorObj).trim();
if (s.startsWith("#")) {
s = s.substring(1);
}
try {
return new Color(Integer.parseUnsignedInt(s, 16));
} catch (NumberFormatException e) {
return Color.WHITE;
}
}
return Color.WHITE;
}
}
ImageCanvas 在渲染时构建 AffineTransform:
public AffineTransform getImageToScreenTransform() {
AffineTransform transform = new AffineTransform();
transform.translate(offsetX, offsetY); // 平移偏移
transform.scale(scale, scale); // 缩放比例
return transform;
}
3. QuickJS 引擎集成 —— JavaScript 与 Java 的桥接
每个画面格子拥有独立的 ScriptContext,内部封装了一个 QuickJS 引擎实例:
public class ScriptContext implements AutoCloseable {
private final QuickJSContext context;
private final DrawingApi drawingApi;
private JSFunction processFunction;
public ScriptContext(int gridIndex, ScriptConsole console) {
this.context = QuickJSContext.create();
this.drawingApi = new DrawingApi();
initialize();
}
private void initialize() {
// 将绘图 API 注册为全局函数
JSObject global = context.getGlobalObject();
global.setProperty("PlotString", (JSCallFunction) args -> {
drawingApi.PlotString(intArg(args, 0), intArg(args, 1),
doubleArg(args, 2), doubleArg(args, 3),
stringArg(args, 4), intArg(args, 5), args[6]);
return null;
});
global.setProperty("PlotLine", (JSCallFunction) args -> {
drawingApi.PlotLine(intArg(args, 0), intArg(args, 1),
doubleArg(args, 2), doubleArg(args, 3),
doubleArg(args, 4), doubleArg(args, 5),
doubleArg(args, 6), args[7]);
return null;
});
// ... 其他绘图函数同理
}
}
消息处理流程:Java 侧将消息对象序列化为 JSON → QuickJS 解析为 JS 对象 → 调用 process(message) 函数:
public void processMessage(Object message) {
lastError = null;
drawingApi.clear(); // 清空上一次的绘图指令
if (processFunction == null) {
return;
}
try {
String json = GSON.toJson(message);
Object jsObject = context.parse(json);
processFunction.call(jsObject);
} catch (QuickJSException e) {
lastError = e.getMessage();
}
}
4. DrawingApi —— 指令收集器
DrawingApi 是 JavaScript 绘图函数在 Java 侧的实现,负责收集绘图指令:
public class DrawingApi {
private final List<DrawingCommand> commands = new ArrayList<>();
private String currentImageBase64;
private boolean clearImageRequested;
@JavascriptMethod
public void PlotLine(int imageWidth, int imageHeight,
double x1, double y1, double x2, double y2,
double thickness, Object color) {
commands.add(new DrawLineCommand(x1, y1, x2, y2,
(float) thickness, CoordinateMapper.parseColor(color)));
}
@JavascriptMethod
public void ShowImage(String base64) {
this.currentImageBase64 = base64;
this.clearImageRequested = false;
}
@JavascriptMethod
public void Clear() {
this.commands.clear();
this.currentImageBase64 = null;
this.clearImageRequested = true;
}
public List<DrawingCommand> getCommands() {
return new ArrayList<>(commands);
}
}
注意 imageWidth 和 imageHeight 参数在函数签名中存在,但当前实现中并未实际使用——坐标映射由渲染层的 AffineTransform 统一处理。保留这两个参数是为了与 Cognex 的绘图函数签名保持一致,方便熟悉 Cognex 的工程师直接迁移脚本。
5. 渲染管线 —— ImageCanvas 的绘制流程
ImageCanvas 是 Swing 画布,负责图像显示和叠加图形的渲染:
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2d = (Graphics2D) g;
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
AffineTransform transform = getImageToScreenTransform();
// 1. 绘制底图
if (currentImage != null) {
g2d.drawImage(currentImage, transform, this);
}
// 2. 叠加绘制脚本生成的图形
if (showCommands) {
for (DrawingCommand cmd : commands) {
try {
cmd.draw(g2d, transform);
} catch (Exception e) {
LOGGER.log(Level.WARNING, "Drawing command failed", e);
}
}
}
// 3. 显示缩放比例
g2d.setColor(Color.YELLOW);
g2d.drawString(String.format("%.0f%%", scale * 100), 10, 20);
}
6. 保存渲染图 —— 离屏渲染
SaveRenderedImage 函数将当前格子的底图 + 叠加图形保存为 JPG 文件。实现方式是创建一个与原图同尺寸的 BufferedImage,使用单位变换(identity transform)在原始分辨率下重放所有绘图指令:
private void saveRenderedImage(String filePath) {
BufferedImage baseImage = decodeBase64Image(drawingApi.getCurrentImageBase64());
if (baseImage == null && renderedImageSupplier != null) {
baseImage = renderedImageSupplier.get();
}
// 创建与原图同尺寸的画布
BufferedImage rendered = new BufferedImage(baseImage.getWidth(), baseImage.getHeight(), BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = rendered.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.drawImage(baseImage, 0, 0, null);
AffineTransform identity = new AffineTransform(); // 单位变换 = 原始分辨率
for (DrawingCommand cmd : drawingApi.getCommands()) {
cmd.draw(g2d, identity);
}
g2d.dispose();
// JPEG 编码,质量 95%
// ...
}
消息流与数据格式
MQTT 消息类型
系统通过 MQTT 接收三类消息,每类消息对应一个话题:
| 消息类型 | MQTT 话题 | 说明 |
|---|---|---|
| 图像 | /autoweld/measure/image/raw |
base64 编码的 JPEG 图像 |
| 测量数据 | /autoweld/measure/data/raw |
宽度、高度、对称度等测量值 |
| 日志 | /autoweld/measure/logs/raw |
检测状态文本 |
XML 消息格式
测量数据消息示例:
<?xml version="1.0" encoding="UTF-8"?>
<DataMessage>
<TimeStamp>202608271857890</TimeStamp>
<ImageHeight>7000</ImageHeight>
<ImageWidth>9344</ImageWidth>
<ProductNumber>1</ProductNumber>
<SurfaceNumber>1</SurfaceNumber>
<BaseLineLeftPointX>10.0</BaseLineLeftPointX>
<BaseLineLeftPointY>500.0</BaseLineLeftPointY>
<BaseLineRightPointX>502.0</BaseLineRightPointX>
<BaseLineRightPointY>500.0</BaseLineRightPointY>
<LeftPointX>50.0</LeftPointX>
<LeftPointY>250.0</LeftPointY>
<RightPointX>462.0</RightPointX>
<RightPointY>250.0</RightPointY>
<ProductWidth>412.0</ProductWidth>
<TopPointX>256.0</TopPointX>
<TopPointY>50.0</TopPointY>
<ProductHeight>450.0</ProductHeight>
<Symmetry>0.12</Symmetry>
</DataMessage>
消息分发机制
每个格子根据 SurfaceNumber 自动过滤消息——格子 1 只处理面 1 的消息,格子 2 只处理面 2 的消息,以此类推。非本格面号的消息不会刷新该格画面。
完整工程源码
工程结构
com.autoweld.cameraviewer
├── App.java // 程序入口
├── config
│ ├── AppConfig.java // 全局配置模型
│ └── ConfigManager.java // JSON 读写
├── mqtt
│ ├── MqttClientManager.java // MQTT 连接管理
│ ├── GridMqttClient.java // 单格子 MQTT 客户端
│ └── MqttMessageListener.java // 消息回调接口
├── model
│ ├── GridConfig.java // 单个格子配置
│ ├── CameraImageMessage.java // 图像消息模型
│ ├── CameraDataMessage.java // 测量数据消息模型
│ └── CameraLogMessage.java // 日志消息模型
├── script
│ ├── ScriptEngine.java // 脚本引擎管理器
│ ├── ScriptContext.java // QuickJS 上下文封装
│ ├── DrawingApi.java // 绘图 API(指令收集器)
│ ├── ScriptConsole.java // 控制台输出捕获
│ └── draw/
│ ├── DrawingCommand.java // 绘图指令接口
│ ├── DrawLineCommand.java // 线段指令
│ ├── DrawCircleCommand.java // 圆指令
│ ├── DrawStringCommand.java // 文字指令
│ ├── DrawPolygonCommand.java // 多边形指令
│ ├── DrawPointCommand.java // 点指令
│ └── CoordinateMapper.java // 坐标映射工具
├── ui
│ ├── MainFrame.java // 主界面 4 宫格
│ ├── GridPanel.java // 单个画面格子
│ ├── SettingsDialog.java // 设置界面
│ ├── ScriptEditorDialog.java // 脚本编辑/测试界面
│ └── ImageCanvas.java // 图像与绘图渲染画布
└── xml
└── XmlParser.java // XML 消息解析
核心代码
ScriptEngine.java —— 管理 4 个格子的脚本上下文
public class ScriptEngine {
private final List<ScriptContext> contexts;
private final List<Consumer<String>> consoleListeners;
public ScriptEngine() {
this.contexts = new ArrayList<>(AppConfig.GRID_COUNT);
for (int i = 0; i < AppConfig.GRID_COUNT; i++) {
final int gridIndex = i;
ScriptConsole console = new ScriptConsole(line -> {
for (Consumer<String> listener : consoleListeners) {
listener.accept("[" + gridIndex + "] " + line);
}
});
contexts.add(new ScriptContext(i, console));
}
}
public void processMessage(int gridIndex, Object message) {
contexts.get(gridIndex).processMessage(message);
}
}
GridMqttClient.java —— 单格 MQTT 客户端
public class GridMqttClient implements MqttCallback {
private static final int QOS = 1;
public synchronized void connect() {
String brokerUrl = "tcp://" + config.getBrokerHost() + ":" + config.getBrokerPort();
MqttClient client = new MqttClient(brokerUrl, clientId, persistence);
MqttConnectOptions options = new MqttConnectOptions();
options.setAutomaticReconnect(true);
options.setCleanSession(true);
client.connect(options);
subscribe(config.getImageTopic(), MessageType.IMAGE);
subscribe(config.getDataTopic(), MessageType.DATA);
subscribe(config.getLogTopic(), MessageType.LOG);
}
private void subscribe(String topic, MessageType type) throws MqttException {
client.subscribe(topic, QOS, (t, msg) -> {
String payload = new String(msg.getPayload(), StandardCharsets.UTF_8);
listener.onMessage(gridIndex, type, payload);
});
}
}
XmlParser.java —— XML 消息解析(兼容无根元素格式)
private Element parseRoot(String xml) throws Exception {
DocumentBuilder builder = factory.newDocumentBuilder();
try {
Document doc = builder.parse(new InputSource(new StringReader(xml)));
return doc.getDocumentElement();
} catch (Exception e) {
// 兼容无根元素的 XML:自动包裹一层 <root>
String content = xml.replaceFirst("<\\?xml[^?]*\\?>", "").trim();
String wrapped = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n" + content + "\n</root>";
Document doc = builder.parse(new InputSource(new StringReader(wrapped)));
return doc.getDocumentElement();
}
}
功能介绍
主界面
- 2×2 四宫格布局,每格显示一路相机画面
- 每格包含:标题栏(名称、连接状态)、图像显示区、测量数据显示区、日志显示区
- 支持图像缩放(适合/最大化/滚轮缩放/拖拽平移)
- 缩放比例实时显示在画布左上角
脚本编辑器
- RSyntaxTextArea 提供 JavaScript 语法高亮
- 支持测试数据模板(图像/数据/日志三种消息类型)
- 控制台实时显示
print()输出 - 预览画布即时渲染绘图结果
设置界面
- 每格可独立配置:名称、MQTT broker 地址与端口、Client ID、三个订阅话题
- 全局选项:启动时最大化、无图像时显示标定板占位图
- 配置以 JSON 形式保存在 jar 同目录
示例脚本
基础示例:显示图像 + 绘制测量结果
function process(message) {
if (message.imageData) {
ShowImage(message.imageData)
}
if (message.productWidth !== undefined) {
PlotString(9344, 7000, 140, 300,
"宽度: " + message.productWidth.toFixed(2), 300, 0x00FF00)
PlotLine(9344, 7000,
message.leftPointX, message.leftPointY,
message.rightPointX, message.rightPointY,
8, 0xFF0000)
}
}
完整示例:工业尺寸测量可视化
这是实际产线使用的脚本,绘制产品宽度/高度测量线、对称度分析、信息面板,并自动保存渲染图:
function process(message) {
const surfaceNum = 1;
if (message.productWidth === undefined) return;
if (message.surfaceNumber != surfaceNum) return;
// 产品名字典
var ProductName = {
"1": "163#J", "2": "6#A-1", "3": "7#A", "4": "8#A",
"5": "146#", "6": "154#", "7": "149#K", "8": "159#-1",
"9": "160#-1", "10": "154#-1", "11": "191#", "12": "196#",
"13": "193#", "14": "30mm标定块", "15": "20mm标定块",
"16": "10mm标定块", "17": "5mm标定块",
GetValue: function(key) {
return this[key] !== undefined ? this[key] : "未知配方";
}
};
// 截断到指定小数位数(非四舍五入)
function truncateToFixed(num, digits) {
var factor = Math.pow(10, digits);
var truncated = Math.trunc(num * factor) / factor;
return truncated.toFixed(digits);
}
// 计算点到线段的垂足
function GetFootOfPerpendicular(x1, y1, x2, y2, x0, y0) {
var dx = x2 - x1, dy = y2 - y1;
if (dx === 0 && dy === 0) return { x: x1, y: y1 };
var t = ((x0 - x1) * dx + (y0 - y1) * dy) / (dx * dx + dy * dy);
if (t < 0) t = 0;
if (t > 1) t = 1;
return { x: x1 + t * dx, y: y1 + t * dy };
}
// 绘制基准线中垂线贯穿线及左右点到中垂线的垂线
function DrawPerpendicularBisector(imgW, imgH, blx, bly, brx, bry, lx, ly, rx, ry) {
var midX = (blx + brx) / 2, midY = (bly + bry) / 2;
var baseDx = brx - blx, baseDy = bry - bly;
var perpVx = baseDy, perpVy = -baseDx;
var perpLenSq = perpVx * perpVx + perpVy * perpVy;
if (perpLenSq === 0) return;
// 中垂线贯穿线(橙色)
var tVals = [];
if (perpVx !== 0) {
tVals.push(-midX / perpVx);
tVals.push((imgW - midX) / perpVx);
}
if (perpVy !== 0) {
tVals.push(-midY / perpVy);
tVals.push((imgH - midY) / perpVy);
}
tVals.sort(function(a, b) { return a - b; });
var tMin = tVals[0], tMax = tVals[tVals.length - 1];
PlotLine(imgW, imgH,
midX + tMin * perpVx, midY + tMin * perpVy,
midX + tMax * perpVx, midY + tMax * perpVy,
8, 0xFFA500);
// 左点到中垂线的垂线(品红)
var tL = ((lx - midX) * perpVx + (ly - midY) * perpVy) / perpLenSq;
PlotLine(imgW, imgH, lx, ly,
midX + tL * perpVx, midY + tL * perpVy, 8, 0xFF00FF);
// 右点到中垂线的垂线(青绿)
var tR = ((rx - midX) * perpVx + (ry - midY) * perpVy) / perpLenSq;
PlotLine(imgW, imgH, rx, ry,
midX + tR * perpVx, midY + tR * perpVy, 8, 0x00FFFF);
}
var imgW = 9344, imgH = 7000;
// 宽度测量线(红色)
PlotLine(imgW, imgH,
message.leftPointX, message.leftPointY,
message.rightPointX, message.rightPointY,
8, 0xFF0000)
// 高度参考线(蓝色)—— 顶点到垂足
var foot = GetFootOfPerpendicular(
message.baseLineLeftPointX, message.baseLineLeftPointY,
message.baseLineRightPointX, message.baseLineRightPointY,
message.topPointX, message.topPointY
);
PlotLine(imgW, imgH,
message.topPointX, message.topPointY,
foot.x, foot.y, 8, 0x0000EE)
// 底座基线(青色)
PlotLine(imgW, imgH,
message.baseLineLeftPointX, message.baseLineLeftPointY,
message.baseLineRightPointX, message.baseLineRightPointY,
8, 0x00FFDD)
// 对称度分析
DrawPerpendicularBisector(imgW, imgH,
message.baseLineLeftPointX, message.baseLineLeftPointY,
message.baseLineRightPointX, message.baseLineRightPointY,
message.leftPointX, message.leftPointY,
message.rightPointX, message.rightPointY)
// 测量点(黄色)
PlotPoint(imgW, imgH, message.leftPointX, message.leftPointY, 30, 0xFFFF00)
PlotPoint(imgW, imgH, message.rightPointX, message.rightPointY, 30, 0xFFFF00)
PlotPoint(imgW, imgH, message.topPointX, message.topPointY, 30, 0xFFFF00)
// 信息面板
PlotString(imgW, imgH, 140, 300,
"配方号: " + message.productNumber + "(" + ProductName.GetValue(message.productNumber) + ")" +
" 面号: " + message.surfaceNumber,
300, 0x0000FF)
PlotString(imgW, imgH, 140, 600,
"对称度: " + truncateToFixed(message.symmetry, 4) + "mm",
300, 0x0000FF)
PlotString(imgW, imgH, 140, 900,
"高度: " + truncateToFixed(message.productHeight, 4) + "mm",
300, 0x0000FF)
PlotString(imgW, imgH, 140, 1200,
"宽度: " + truncateToFixed(message.productWidth, 4) + "mm",
300, 0x0000FF)
PlotString(imgW, imgH, 140, 1500,
"时间: " + message.timestamp,
300, 0x0000FF)
// 保存渲染图到分类目录
var savePath = "D:/CCD图片/整件-尺寸检测/"
+ GetTimeStamp("yyyyMMdd") + "/"
+ ProductName.GetValue(message.productNumber) + "/面"
+ message.surfaceNumber + "/面"
+ message.surfaceNumber + "渲染图_"
+ message.timestamp + ".jpg";
SaveRenderedImage(savePath);
}
该脚本在实际产线中运行的效果如下图所示——相机图像上叠加了宽度/高度测量线、对称度分析线、测量点标记和信息面板,并自动保存到分类目录:

绘图函数测试脚本
function process(message) {
var imgW = 5120, imgH = 5120;
// 参考矩形边框
PlotPolygon(imgW, imgH,
[100, 100, 5020, 100, 5020, 5020, 100, 5020],
10, 0x00FFFF)
// 对角线
PlotLine(imgW, imgH, 100, 100, 5020, 5020, 8, 0xFF0000)
PlotLine(imgW, imgH, 5020, 100, 100, 5020, 8, 0xFF0000)
// 中心圆
PlotCircle(imgW, imgH, 2560, 2560, 800, 12, 0x00FF00)
// 角点
PlotPoint(imgW, imgH, 100, 100, 40, 0xFFFF00)
PlotPoint(imgW, imgH, 5020, 100, 40, 0xFFFF00)
PlotPoint(imgW, imgH, 5020, 5020, 40, 0xFFFF00)
PlotPoint(imgW, imgH, 100, 5020, 40, 0xFFFF00)
// 文字标签
PlotString(imgW, imgH, 200, 200,
"左上角 (100,100)", 80, 0xFFFFFF)
PlotString(imgW, imgH, 3200, 200,
"右上角 (5020,100)", 80, 0xFFFFFF)
print("绘图测试完成")
}
脚本编写指南
本节面向现场工程师,介绍如何为 CameraViewerAutoWeld 编写 JavaScript 脚本。
快速开始
- 在主界面点击菜单 脚本 → 编辑格子 N 脚本。
- 在编辑器中编写
process(message)函数。 - 点击 运行测试,选择消息类型并加载测试模板。
- 观察右侧预览与底部控制台输出。
- 点击 保存到配置 或 应用并关闭。
脚本入口
脚本必须包含一个 process(message) 函数。当对应格子收到 MQTT 消息时,该函数会被调用:
function process(message) {
// 处理消息并调用绘图函数
}
消息对象结构
图像消息
对应 MQTT 话题 /autoweld/measure/image/raw:
{
timestamp: "202608271857890",
imageHeight: 5120,
imageWidth: 5120,
productNumber: 1,
surfaceNumber: 1,
imageData: "...base64编码的图像数据..."
}
面号分发:图像消息会根据
surfaceNumber自动显示到对应格子,即格子 1 显示面 1 的图像,格子 2 显示面 2 的图像,以此类推。非本格面号的图像不会刷新该格画面。
测量数据消息
对应 MQTT 话题 /autoweld/measure/data/raw:
{
timestamp: "202608271857890",
imageHeight: 5120,
imageWidth: 5120,
productNumber: 1,
surfaceNumber: 1,
baseLineLeftPointX: 10.0,
baseLineLeftPointY: 500.0,
baseLineRightPointX: 502.0,
baseLineRightPointY: 500.0,
leftPointX: 50.0,
leftPointY: 250.0,
rightPointX: 462.0,
rightPointY: 250.0,
productWidth: 412.0,
topPointX: 256.0,
topPointY: 50.0,
productHeight: 450.0,
symmetry: 0.12
}
日志消息
对应 MQTT 话题 /autoweld/measure/logs/raw:
{
timestamp: "202608271857890",
imageHeight: 5120,
imageWidth: 5120,
productNumber: 1,
surfaceNumber: 1,
message: "检测完成"
}
内置脚本函数
坐标系说明
所有绘图函数均使用左手坐标系:原点位于图像左上角,X 向右增长,Y 向下增长。
绘图函数
PlotString —— 在指定像素坐标绘制文字:
PlotString(imageWidth, imageHeight, x, y, text, fontSize, color)
PlotLine —— 绘制线段:
PlotLine(imageWidth, imageHeight, x1, y1, x2, y2, thickness, color)
PlotCircle —— 绘制圆(描边):
PlotCircle(imageWidth, imageHeight, centerX, centerY, diameter, thickness, color)
PlotPolygon —— 绘制多边形(描边):
PlotPolygon(imageWidth, imageHeight, [x0, y0, x1, y1, ...], thickness, color)
PlotPoint —— 绘制实心点:
PlotPoint(imageWidth, imageHeight, x, y, thickness, color)
ShowImage —— 显示 base64 编码的图像。支持纯 base64 字符串,也支持带 data:image/xxx;base64, 前缀的 data URI:
ShowImage(base64String)
图像与工具函数
SaveImage —— 将 base64 编码的图片直接解码并保存到指定路径。支持 data:image/xxx;base64, 前缀;目标目录不存在时会自动创建:
SaveImage(base64String, filePath)
示例:
SaveImage(message.imageData, "D:/snap/raw_" + GetTimeStamp("yyyyMMdd_HHmmss") + ".jpg")
SaveRenderedImage —— 保存当前格子中已渲染的图像(底图 + 脚本叠加图形),输出为 JPG 格式,质量 95%。目标目录不存在时会自动创建:
SaveRenderedImage(filePath)
注意:该函数保存的是调用时刻格子中已显示的画面。如果在处理 IMAGE 消息的 process(message) 中调用,保存的是上一帧(当前帧底图尚未刷新到界面);在 DATA/LOG 消息中调用或配合按钮触发时,可保存当前已渲染画面。
示例:
SaveRenderedImage("D:/snap/render_" + GetTimeStamp("yyyyMMdd_HHmmss") + ".jpg")
Clear —— 清除当前格子的图像与脚本叠加图形:
Clear()
示例:
function process(message) {
if (message.message === "停止检测") {
Clear()
print("已清屏")
return
}
if (message.imageData) {
ShowImage(message.imageData)
}
}
GetTimeStamp —— 返回当前时间字符串,format 与 Java DateTimeFormatter 格式一致。若传入空字符串,默认格式为 yyyyMMdd_HHmmss:
GetTimeStamp(format)
示例:
GetTimeStamp("yyyyMMdd_HHmmss") // 20260828_065812
GetTimeStamp("yyyy-MM-dd HH:mm:ss") // 2026-08-28 06:58:12
GetTimeStamp("yyyyMMdd_HHmmss_SSS") // 20260828_065812_123
颜色
color 参数支持以下形式:
- 整数 RGB:
0xFF0000(红色) - 十六进制字符串:
"#FF0000"或"FF0000"
常用示例
示例 1:显示图像
function process(message) {
if (message.imageData) {
ShowImage(message.imageData)
}
}
示例 2:绘制测量结果
在图像上绘制测量数据:配方号、面号、OK 状态、对称度、高度、宽度与时间。
function process(message) {
if (message.productWidth === undefined) {
return
}
// 使用 test1.jpg 的实际尺寸 9344x7000 作为绘图坐标系
var imgW = 9344
var imgH = 7000
// 绘制宽度测量线(红色)
PlotLine(imgW, imgH,
message.leftPointX, message.leftPointY,
message.rightPointX, message.rightPointY,
8, 0xFF0000)
// 绘制高度参考线(蓝色)
PlotLine(imgW, imgH,
message.topPointX, message.topPointY,
message.topPointX, message.baseLineLeftPointY,
8, 0x0000FF)
// 绘制测量点(黄色)
PlotPoint(imgW, imgH, message.leftPointX, message.leftPointY, 30, 0xFFFF00)
PlotPoint(imgW, imgH, message.rightPointX, message.rightPointY, 30, 0xFFFF00)
PlotPoint(imgW, imgH, message.topPointX, message.topPointY, 30, 0xFFFF00)
// 左上角信息面板背景框(青色边框)
PlotPolygon(imgW, imgH,
[80, 80, 2200, 80, 2200, 900, 80, 900],
6, 0x00FFFF)
// 配方号、面号
PlotString(imgW, imgH, 140, 200,
"配方号: " + message.productNumber + " 面号: " + message.surfaceNumber,
90, 0xFFFFFF)
// OK(绿色大号文字)
PlotString(imgW, imgH, 140, 380,
"OK",
180, 0x00FF00)
// 对称度、高度、宽度
PlotString(imgW, imgH, 140, 540,
"对称度: " + message.symmetry.toFixed(4),
90, 0xFFFFFF)
PlotString(imgW, imgH, 140, 660,
"高度: " + message.productHeight.toFixed(4),
90, 0xFFFFFF)
PlotString(imgW, imgH, 140, 780,
"宽度: " + message.productWidth.toFixed(2),
90, 0xFFFFFF)
// 时间
PlotString(imgW, imgH, 140, 880,
"时间: " + message.timestamp,
70, 0xAAAAAA)
}
示例 3:绘制产品轮廓框
function process(message) {
var imgW = message.imageWidth || 5120
var imgH = message.imageHeight || 5120
var left = message.leftPointX
var top = message.topPointY
var right = message.rightPointX
var bottom = message.baseLineLeftPointY
PlotPolygon(imgW, imgH,
[left, top, right, top, right, bottom, left, bottom],
4, 0x00FFFF)
}
示例 4:不同消息类型分别处理
function process(message) {
if (message.imageData) {
ShowImage(message.imageData)
} else if (message.productWidth !== undefined) {
PlotString(5120, 5120, 100, 100,
"W=" + message.productWidth.toFixed(2),
60, 0xFFFFFF)
} else if (message.message) {
print("日志: " + message.message)
}
}
示例 5:综合绘图函数测试
用于验证所有内置绘图函数,可在脚本编辑器中选择"数据"类型并加载模板后运行测试。
function process(message) {
var imgW = 5120
var imgH = 5120
// 1. 绘制参考矩形边框
PlotPolygon(imgW, imgH,
[100, 100, 5020, 100, 5020, 5020, 100, 5020],
10, 0x00FFFF)
// 2. 绘制对角线
PlotLine(imgW, imgH, 100, 100, 5020, 5020, 8, 0xFF0000)
PlotLine(imgW, imgH, 5020, 100, 100, 5020, 8, 0xFF0000)
// 3. 绘制中心圆
PlotCircle(imgW, imgH, 2560, 2560, 800, 12, 0x00FF00)
// 4. 绘制角点
PlotPoint(imgW, imgH, 100, 100, 40, 0xFFFF00)
PlotPoint(imgW, imgH, 5020, 100, 40, 0xFFFF00)
PlotPoint(imgW, imgH, 5020, 5020, 40, 0xFFFF00)
PlotPoint(imgW, imgH, 100, 5020, 40, 0xFFFF00)
// 5. 绘制文字标签
PlotString(imgW, imgH, 200, 200,
"左上角 (100,100)", 80, 0xFFFFFF)
PlotString(imgW, imgH, 3200, 200,
"右上角 (5020,100)", 80, 0xFFFFFF)
PlotString(imgW, imgH, 200, 4900,
"左下角 (100,5020)", 80, 0xFFFFFF)
// 6. 绘制测量数据(假设收到数据消息)
if (message.productWidth !== undefined) {
PlotLine(imgW, imgH,
message.leftPointX, message.leftPointY,
message.rightPointX, message.rightPointY,
15, 0xFF00FF)
PlotString(imgW, imgH, 200, 300,
"宽度: " + message.productWidth.toFixed(2),
100, 0xFFAA00)
PlotString(imgW, imgH, 200, 450,
"高度: " + message.productHeight.toFixed(2),
100, 0xFFAA00)
PlotString(imgW, imgH, 200, 600,
"对称度: " + message.symmetry.toFixed(3),
100, 0xFFAA00)
}
print("绘图测试完成")
}
示例 6:显示测试图像
用于验证 ShowImage 函数,可在脚本编辑器中选择"图像"类型并粘贴 base64 测试图像后运行。
function process(message) {
if (message.imageData && message.imageData.length > 0) {
ShowImage(message.imageData)
print("图像尺寸: " + message.imageWidth + "x" + message.imageHeight)
} else {
print("无图像数据")
}
}
示例 7:保存图像
在收到测量数据时保存当前渲染图,文件名带时间戳。
function process(message) {
if (message.imageData) {
ShowImage(message.imageData)
}
if (message.productWidth !== undefined) {
var imgW = message.imageWidth || 5120
var imgH = message.imageHeight || 5120
// 绘制测量结果
PlotLine(imgW, imgH,
message.leftPointX, message.leftPointY,
message.rightPointX, message.rightPointY,
8, 0xFF0000)
PlotString(imgW, imgH, 100, 100,
"宽度: " + message.productWidth.toFixed(2),
60, 0x00FF00)
// 保存渲染图(底图 + 叠加图形)
var ts = GetTimeStamp("yyyyMMdd_HHmmss")
SaveRenderedImage("D:/snap/render_" + ts + ".jpg")
// 也可以直接保存原始图像
// SaveImage(message.imageData, "D:/snap/raw_" + ts + ".jpg")
print("已保存渲染图: " + ts)
}
}
调试技巧
使用 print 输出
脚本中可以使用 print() 输出到控制台:
function process(message) {
print("收到消息: " + JSON.stringify(message))
}
使用运行测试
脚本编辑器提供测试数据模板,可模拟图像、数据、日志消息,无需等待真实 MQTT 数据即可验证脚本。
常见错误
- 未找到 process 函数:脚本必须定义
process(message)。 - 颜色不显示:确认颜色参数为整数或有效十六进制字符串。
- 坐标错位:检查传入的
imageWidth/imageHeight是否与消息中的图像尺寸一致。 - 图像不显示:确认
ShowImage接收的是有效 base64 字符串。 - 保存图像失败:确认路径合法且有写入权限;
SaveImage需传入有效 base64,SaveRenderedImage需在格子已有图像时调用。 - 保存渲染图为上一帧:
SaveRenderedImage在IMAGE消息处理期间调用时保存的是上一帧,建议在DATA/LOG消息中调用。
关键设计决策
| 问题 | 决策 | 理由 |
|---|---|---|
| 每格子一个 MQTT client 还是共享一个? | 每格子独立 | 便于分别设置 broker 和重连 |
| 脚本接收原始 XML 还是已解析对象? | 已解析对象 | 降低脚本复杂度,Gson 序列化为 JSON 后由 QuickJS 解析 |
| 坐标系 | 左手坐标系,原点左上角 | 与图像处理惯例一致,与 Cognex 保持兼容 |
| 图像缩放策略 | Fit 模式 + 手动缩放 | 保持宽高比,黑边填充,支持滚轮缩放和拖拽平移 |
| 脚本隔离 | 每格子独立 QuickJS context | 避免全局变量冲突,单格脚本崩溃不影响其他格 |
| 绘图指令生命周期 | 每次消息清空重绘 | 无状态设计,避免指令累积导致内存泄漏 |
| 配置保存时机 | 设置界面点击"应用"时保存 | 避免频繁写文件 |
构建与运行
# 一键打包(自动构建并生成带时间戳的 fat jar)
./build-with-timestamp.sh
# 手动构建
gradle-dist/gradle-9.7.1/bin/gradle fatJar --offline
# 运行
java -jar camera-viewer-1.0.0-fat_*.jar
项目完全离线构建,所有依赖均已放在 libs/ 目录,适合在无法访问外网的工业现场环境中编译部署。

在 Java 桌面应用中集成 QuickJS 脚本引擎,设计一套面向工业机器视觉的绘图指令集,让用户通过 JavaScript 脚本将测量结果(宽度、高度、对称度等)实时绘制在相机图像上,实现四宫格多路画面的可视化监控。
浙公网安备 33010602011771号