摸鱼笔记[11]-使用quickjs设计绘图指令绘制机器视觉结果图

摘要

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

声明

本文内容由 AI 辅助生成, 已经人工审核和编辑。

简介

Cognex 智能相机的绘图函数

Cognex(康耐视)是全球领先的机器视觉公司,其 Insight 系列智能相机在工业检测领域广泛应用。Insight 相机内置 EasyBuilder 界面和电子表格环境,用户可以通过脚本对检测结果进行图形化标注。

Cognex 智能相机的绘图函数设计理念非常简洁——面向过程的绘图指令

函数 功能
PlotString 在指定像素坐标绘制文字
PlotLine 绘制线段
PlotCircle 绘制圆(描边)
PlotPolygon 绘制多边形(描边)
PlotPoint 绘制实心点

这些函数的共同特点是:

  • 前两个参数固定为图像尺寸(宽、高),用于建立坐标映射
  • 使用左手坐标系:原点位于图像左上角,X 向右增长,Y 向下增长
  • 像素级坐标:所有坐标值都是基于原始图像分辨率的像素值
  • RGB 颜色:支持整数(如 0xFF0000)或十六进制字符串(如 "#FF0000"

这种设计使得脚本编写者无需关心屏幕显示尺寸和缩放比例,只需要按照图像原始分辨率来指定坐标即可,渲染层会自动处理坐标变换。

面向过程的工业控制脚本思想

工业控制场景下的脚本语言设计与通用编程有显著不同,核心思想是面向过程、指令驱动

  1. 无状态执行:每次收到消息,脚本从头执行一遍绘图指令,不维护复杂的 UI 状态
  2. 指令即绘制:每个绘图函数调用就是一条绘图指令,引擎收集所有指令后统一渲染
  3. 声明式布局:脚本只描述"画什么、在哪里画",不关心"怎么画"
  4. 快速迭代:现场工程师可以修改脚本后立即看到效果,无需重新编译主程序

这种设计模式在工业软件中非常实用——它把"业务逻辑"(测量数据的解析和计算)和"渲染逻辑"(如何在屏幕上绘制)解耦,让非专业程序员也能快速上手。

工程

项目概述

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

注意 imageWidthimageHeight 参数在函数签名中存在,但当前实现中并未实际使用——坐标映射由渲染层的 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);
}

该脚本在实际产线中运行的效果如下图所示——相机图像上叠加了宽度/高度测量线、对称度分析线、测量点标记和信息面板,并自动保存到分类目录:

JustStupidCameraHelper相机显示_2606120922CameraViewerAutoWeld面1渲染图_副本

绘图函数测试脚本

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 脚本。

快速开始

  1. 在主界面点击菜单 脚本 → 编辑格子 N 脚本
  2. 在编辑器中编写 process(message) 函数。
  3. 点击 运行测试,选择消息类型并加载测试模板。
  4. 观察右侧预览与底部控制台输出。
  5. 点击 保存到配置应用并关闭

脚本入口

脚本必须包含一个 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 需在格子已有图像时调用。
  • 保存渲染图为上一帧SaveRenderedImageIMAGE 消息处理期间调用时保存的是上一帧,建议在 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/ 目录,适合在无法访问外网的工业现场环境中编译部署。

posted @ 2026-09-13 13:29  qsBye  阅读(7)  评论(0)    收藏  举报