news 2026/4/23 14:42:32

通义千问3-VL-Plus - 界面交互(坐标改进)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
通义千问3-VL-Plus - 界面交互(坐标改进)

目录

一、引言

二、代码修改

1. 先引入依赖

2. 核心工具类(含 Point 实体 + 映射逻辑)

3.OparetionServiceImpl 实现类

三、运行结果演示


一、引言

在前文 通义千问3-VL-Plus - 界面交互(本地图片改进)-CSDN博客 中我们完成了对GUI模型的接入,但是我发现定位好像不准确,一番查看后发现,原来读取的是压缩图片的定位,想要获取正确的还需要再进一步修改。

二、代码修改

1. 先引入依赖

<!-- Maven依赖 --> <dependencies> <!-- OkHttp网络请求 --> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency> <!-- 图像处理(JDK自带,无需额外引入) --> <dependency> <groupId>javax.imageio</groupId> <artifactId>imageio-api</artifactId> <version>1.5.1</version> <scope>provided</scope> </dependency> </dependencies>

2. 核心工具类(含 Point 实体 + 映射逻辑)

package gzj.spring.ai.util; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import org.apache.commons.codec.binary.Base64; import org.apache.commons.io.FileUtils; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.*; import java.math.RoundingMode; import java.net.HttpURLConnection; import java.net.URL; import java.text.DecimalFormat; /** * 坐标映射工具类(兼容本地图片/网络URL + Base64转换 + 模型坐标→原始坐标) */ public class CoordinateMappingUtil { // 静态OkHttp客户端(复用连接) private static final OkHttpClient OK_HTTP_CLIENT = new OkHttpClient().newBuilder().build(); /** * 坐标点实体类 */ public static class Point { private int x; private int y; public Point(int x, int y) { this.x = x; this.y = y; } // Getter/Setter public int getX() { return x; } public void setX(int x) { this.x = x; } public int getY() { return y; } public void setY(int y) { this.y = y; } @Override public String toString() { return "原始图像坐标(x=" + x + ", y=" + y + ")"; } } // ====================== 新增:本地图片转Base64 ====================== /** * 本地图片转Base64(带data:image前缀,兼容GUI-Plus模型输入) * @param localImagePath 本地图片绝对路径(如E:\\test.png) * @return 带前缀的Base64字符串 * @throws IOException 文件读取异常 */ public static String localImageToBase64(String localImagePath) throws IOException { File imageFile = new File(localImagePath); // 校验文件存在性 if (!imageFile.exists()) { throw new FileNotFoundException("本地图片不存在:" + localImagePath); } // 读取文件字节 byte[] imageBytes = FileUtils.readFileToByteArray(imageFile); // Base64编码 String base64Str = Base64.encodeBase64String(imageBytes); // 自动识别图片格式 String suffix = localImagePath.substring(localImagePath.lastIndexOf(".") + 1).toLowerCase(); if (!suffix.matches("png|jpg|jpeg|bmp")) { suffix = "png"; // 默认PNG } // 拼接data:image前缀 return String.format("data:image/%s;base64,%s", suffix, base64Str); } // ====================== 核心:统一读取图片(本地/网络) ====================== /** * 统一读取图片(自动识别本地路径/网络URL) * @param imageSource 本地图片路径(如E:\\test.png)或网络URL(http/https开头) * @return 图片的BufferedImage(用于获取原始宽高) * @throws IOException 读取异常 */ private static BufferedImage getImage(String imageSource) throws IOException { // 判定是否为网络URL if (imageSource.startsWith("http://") || imageSource.startsWith("https://")) { return getImageFromUrl(imageSource); } else { // 本地图片路径 File localFile = new File(imageSource); if (!localFile.exists()) { throw new FileNotFoundException("本地图片不存在:" + imageSource); } return ImageIO.read(localFile); } } /** * 从网络URL读取图片 */ private static BufferedImage getImageFromUrl(String imageUrl) throws IOException { // 兼容JDK原生URL(兜底OkHttp) try { URL url = new URL(imageUrl); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setConnectTimeout(5000); conn.setReadTimeout(5000); conn.setRequestMethod("GET"); if (conn.getResponseCode() == 200) { return ImageIO.read(conn.getInputStream()); } else { throw new IOException("网络图片读取失败,响应码:" + conn.getResponseCode()); } } catch (Exception e) { // 降级使用OkHttp Request request = new Request.Builder().url(imageUrl).get().build(); try (Response response = OK_HTTP_CLIENT.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IOException("OkHttp读取图片失败,响应码:" + response.code()); } byte[] imageBytes = response.body().bytes(); return ImageIO.read(new ByteArrayInputStream(imageBytes)); } } } // ====================== 核心:坐标映射(兼容本地/网络图片) ====================== /** * 模型坐标映射到原始图像坐标(支持本地图片路径/网络URL) * @param imageSource 本地图片路径 或 网络URL * @param modelPoint 模型返回的坐标(基于内部缩放图) * @param factor 缩放基数(默认28) * @param maxPixels 最大像素值(默认1280*28*28) * @param minPixels 最小像素值(默认4*28*28) * @return 原始图像的绝对坐标 * @throws IOException 读取图片异常 */ public static Point smartSize(String imageSource, Point modelPoint, Integer factor, Long maxPixels, Long minPixels) throws IOException { // 1. 默认参数 int defaultFactor = factor == null ? 28 : factor; long defaultMaxPixels = maxPixels == null ? 1280 * 28 * 28 : maxPixels; long defaultMinPixels = minPixels == null ? 4 * 28 * 28 : minPixels; // 2. 统一读取图片(本地/网络),获取原始尺寸 BufferedImage originalImage = getImage(imageSource); int originalWidth = originalImage.getWidth(); int originalHeight = originalImage.getHeight(); // 3. 初始调整宽高为factor的整数倍(round取整) double hBar = Math.round((double) originalHeight / defaultFactor) * defaultFactor; double wBar = Math.round((double) originalWidth / defaultFactor) * defaultFactor; // 4. 计算缩放因子beta(根据像素阈值调整) double totalPixels = hBar * wBar; double beta = 1.0; if (totalPixels > defaultMaxPixels) { beta = Math.sqrt((originalHeight * originalWidth) / (double) defaultMaxPixels); hBar = Math.floor(originalHeight / beta / defaultFactor) * defaultFactor; wBar = Math.floor(originalWidth / beta / defaultFactor) * defaultFactor; } else if (totalPixels < defaultMinPixels) { beta = Math.sqrt(defaultMinPixels / (originalHeight * originalWidth)); hBar = Math.ceil(originalHeight * beta / defaultFactor) * defaultFactor; wBar = Math.ceil(originalWidth * beta / defaultFactor) * defaultFactor; } // 5. 模型坐标 → 原始坐标(四舍五入) DecimalFormat df = new DecimalFormat("#"); df.setRoundingMode(RoundingMode.HALF_UP); int originalX = Integer.parseInt(df.format((double) modelPoint.getX() / wBar * originalWidth)); int originalY = Integer.parseInt(df.format((double) modelPoint.getY() / hBar * originalHeight)); return new Point(originalX, originalY); } // ====================== 测试示例 ====================== // public static void main(String[] args) { // try { // // 测试1:网络URL场景 // String netImageUrl = "https://p3-flow-imagex-sign.byteimg.com/tos-cn-i-a9rns2rl98/1e8c83f9e6b94f428e21c754d1265406.png"; // Point modelPoint1 = new Point(1205, 278); // Point netOriginalPoint = smartSize(netImageUrl, modelPoint1, null, null, null); // System.out.println("【网络图片】模型坐标:" + modelPoint1.getX() + "," + modelPoint1.getY()); // System.out.println("【网络图片】原始坐标:" + netOriginalPoint); // // // 测试2:本地图片场景(转Base64 + 坐标映射) // String localImagePath = "E:\\screenshot\\desktop.png"; // 替换为你的本地路径 // // 本地图片转Base64(供模型调用) // String base64Str = localImageToBase64(localImagePath); // System.out.println("【本地图片】Base64(前50字符):" + base64Str.substring(0, 50) + "..."); // // 本地图片坐标映射 // Point modelPoint2 = new Point(1205, 278); // Point localOriginalPoint = smartSize(localImagePath, modelPoint2, null, null, null); // System.out.println("【本地图片】模型坐标:" + modelPoint2.getX() + "," + modelPoint2.getY()); // System.out.println("【本地图片】原始坐标:" + localOriginalPoint); // // } catch (IOException e) { // e.printStackTrace(); // } // } }

3.OparetionServiceImpl 实现类

package gzj.spring.ai.Service.ServiceImpl; import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation; import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam; import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult; import com.alibaba.dashscope.common.MultiModalMessage; import com.alibaba.dashscope.common.Role; import com.alibaba.dashscope.exception.ApiException; import com.alibaba.dashscope.exception.NoApiKeyException; import com.alibaba.dashscope.exception.UploadFileException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import gzj.spring.ai.Request.OparetionRequest; import gzj.spring.ai.Service.OparetionService; import gzj.spring.ai.util.CoordinateMappingUtil; import io.reactivex.Flowable; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.awt.*; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; import java.util.List; import static com.alibaba.cloud.ai.graph.utils.TryConsumer.log; import static gzj.spring.ai.util.CoordinateMappingUtil.smartSize; /** * @author DELL */ @Service public class OparetionServiceImpl implements OparetionService { @Value("${spring.ai.dashscope.api-key}") private String apiKey; @Value("${spring.ai.dashscope.modelV2:gui-plus}") private String modelName; // 模型名配置化,便于切换 /** * 工具方法:本地图片转Base64(带data:image前缀,GUI-Plus支持格式) */ private String encodeLocalImageToBase64(String localPath) throws IOException { Path imagePath = Paths.get(localPath); // 校验文件存在性 if (!Files.exists(imagePath)) { throw new IOException("本地图片不存在:" + localPath); } // 读取文件并Base64编码(修复原有编码错误) byte[] imageBytes = Files.readAllBytes(imagePath); String base64Str = Base64.getEncoder().encodeToString(imageBytes); // 自动识别图片格式 String suffix = localPath.substring(localPath.lastIndexOf(".") + 1).toLowerCase(); if (!Arrays.asList("png", "jpg", "jpeg").contains(suffix)) { suffix = "png"; // 默认PNG } return String.format("data:image/%s;base64,%s", suffix, base64Str); } /** * 工具方法:构建图片内容(优先级:本地图片 > 网络URL) */ private String buildImageContent(OparetionRequest request) throws IOException { if (request.getLocalImagePath() != null && !request.getLocalImagePath().isEmpty()) { log.info("使用本地图片:{}", request.getLocalImagePath()); return encodeLocalImageToBase64(request.getLocalImagePath()); } else if (request.getImageUrl() != null && !request.getImageUrl().isEmpty()) { log.info("使用网络图片URL:{}", request.getImageUrl()); return request.getImageUrl(); } else { throw new IllegalArgumentException("必须传入imageUrl(网络图片)或localImagePath(本地图片)"); } } /** * 构建GUI-Plus核心提示词(优化为Text Blocks,提升可读性) */ private String buildSystemPrompt() { return """ ## 1. 核心角色 (Core Role) 你是一个顶级的AI视觉操作代理。你的任务是分析电脑屏幕截图,理解用户的指令,然后将任务分解为单一、精确的GUI原子操作。 ## 2. [CRITICAL] JSON Schema & 绝对规则(必须严格遵守) 你的输出必须是一个**完整、合法、可直接解析**的JSON对象,任何情况下都不能截断、遗漏字段、缺少闭合符号。 ### 强制规则 - [R1] 纯JSON输出:回复只能是JSON对象,无任何前缀、后缀、注释、解释性文字。 - [R2] 字段必填性: - 所有Action的parameters字段必须包含模板中**所有必填键**(如CLICK必须有x、y整数,缺一不可); - x/y必须是**单个整数**(禁止数组/空值),代表屏幕坐标(像素); - thought字段必须是一句话,描述思考过程,不能为空。 - [R3] Action值规范:只能是 CLICK/TYPE/SCROLL/KEY_PRESS/FINISH/FAIL(大写、无空格)。 - [R4] JSON格式校验:生成后必须自检——确保大括号闭合、逗号正确、字符串用双引号、数值无引号。 ## 3. 工具集 (Available Actions) ### CLICK(必填x、y,可选description) - 功能: 单击屏幕。 - 坐标规则: x、y是**当前截图的像素坐标**——以截图的左上角为原点,向右为x轴正方向,向下为y轴正方向,坐标值为截图内的实际像素数值(例如截图宽度是1920像素,则x最大为1919)。 - 必须返回如下完整JSON结构(x/y为截图内的实际像素整数): { "thought": "一句话描述思考过程", "action": "CLICK", "parameters": { "x": 1753, "y": 278, "description": "截图中右上角的豆包应用图标" } } ### TYPE(必填text、needs_enter) - 功能: 输入文本。 - 必须返回如下完整JSON结构: { "thought": "一句话描述思考过程", "action": "TYPE", "parameters": { "text": "要输入的文本", "needs_enter": true/false } } ### SCROLL(必填direction、amount) - 功能: 滚动窗口。 - 必须返回如下完整JSON结构: { "thought": "一句话描述思考过程", "action": "SCROLL", "parameters": { "direction": "up/down", "amount": "small/medium/large" } } ### KEY_PRESS(必填key) - 功能: 按下功能键。 - 必须返回如下完整JSON结构: { "thought": "一句话描述思考过程", "action": "KEY_PRESS", "parameters": { "key": "enter/esc/alt+f4等" } } ### FINISH(必填message) - 功能: 任务成功完成。 - 必须返回如下完整JSON结构: { "thought": "一句话描述思考过程", "action": "FINISH", "parameters": { "message": "总结任务完成情况" } } ### FAIL(必填reason) - 功能: 任务无法完成。 - 必须返回如下完整JSON结构: { "thought": "一句话描述思考过程", "action": "FAIL", "parameters": { "reason": "清晰解释失败原因" } } ## 4. 思维与决策框架 1. 目标分析: 用户的最终目标是什么? 2. 屏幕观察: 仅基于截图中的视觉证据决策,看不见的元素不交互。 3. 行动决策: 选择最合适的Action,确保parameters字段完整。 4. 最终校验: 检查JSON是否完整闭合、字段是否必填、格式是否合法,再输出。 """; } /** * 非流式调用(保留原有逻辑,兼容本地图片) */ @Override public String operation(OparetionRequest request) throws ApiException, NoApiKeyException, UploadFileException, IOException { // 1. 校验核心参数 if (request.getText() == null || request.getText().isEmpty()) { throw new IllegalArgumentException("用户指令text不能为空"); } // 2. 初始化客户端 MultiModalConversation conv = new MultiModalConversation(); // 3. 构建系统提示词 MultiModalMessage systemMsg = MultiModalMessage.builder() .role(Role.SYSTEM.getValue()) .content(Collections.singletonList(Collections.singletonMap("text", buildSystemPrompt()))) .build(); // 4. 构建用户消息(图片+文本) String imageContent = buildImageContent(request); MultiModalMessage userMessage = MultiModalMessage.builder() .role(Role.USER.getValue()) .content(Arrays.asList( Collections.singletonMap("image", imageContent), Collections.singletonMap("text", request.getText()) )).build(); // 5. 构建请求参数(修复API Key使用矛盾) MultiModalConversationParam param = MultiModalConversationParam.builder() // 统一使用配置文件的API Key .apiKey(apiKey) .model(modelName) .messages(Arrays.asList(systemMsg, userMessage)) .build(); // 6. 同步调用+结果解析(增加空指针防护) MultiModalConversationResult result = conv.call(param); if (result == null || result.getOutput() == null || result.getOutput().getChoices() == null || result.getOutput().getChoices().isEmpty()) { log.warn("GUI-Plus返回结果为空"); // 返回空JSON,避免前端解析异常 return "{}"; } List<Map<String, Object>> content = result.getOutput().getChoices().get(0).getMessage().getContent(); String resText = content != null && !content.isEmpty() ? content.get(0).get("text").toString() : "{}"; log.info("GUI-Plus非流式调用完成,结果:{}", resText); // 坐标映射至实际原始图像 try { // 尝试解析模型返回的JSON文本 ObjectMapper objectMapper = new ObjectMapper(); JsonNode jsonNode = objectMapper.readTree(resText); int x = jsonNode.path("parameters").path("x").asInt(); int y = jsonNode.path("parameters").path("y").asInt(); CoordinateMappingUtil.Point modelPoint = new CoordinateMappingUtil.Point(x, y); CoordinateMappingUtil.Point point = CoordinateMappingUtil.smartSize(request.getLocalImagePath(), modelPoint, null, null, null); log.info("映射后的坐标:{}", point); } catch (Exception e) { log.error("解析模型返回结果失败", e); } return resText; } /** * 新增:SSE流式调用(实时推送结果) */ @Override public SseEmitter streamOperation(OparetionRequest request) { // 设置SSE超时时间(30秒) SseEmitter emitter = new SseEmitter(30000L); // 超时回调 emitter.onTimeout(() -> handleEmitterError(emitter, "SSE连接超时(30秒)")); // 客户端关闭回调 emitter.onCompletion(() -> log.info("SSE连接已关闭")); // 异步执行流式调用(避免阻塞主线程) new Thread(() -> { MultiModalConversation conv = new MultiModalConversation(); try { // 1. 校验参数 if (request.getText() == null || request.getText().isEmpty()) { throw new IllegalArgumentException("用户指令text不能为空"); } // 2. 构建图片内容+消息 String imageContent = buildImageContent(request); MultiModalMessage systemMsg = MultiModalMessage.builder() .role(Role.SYSTEM.getValue()) .content(Collections.singletonList(Collections.singletonMap("text", buildSystemPrompt()))) .build(); MultiModalMessage userMessage = MultiModalMessage.builder() .role(Role.USER.getValue()) .content(Arrays.asList( Collections.singletonMap("image", imageContent), Collections.singletonMap("text", request.getText()) )).build(); // 3. 构建流式请求参数 MultiModalConversationParam param = MultiModalConversationParam.builder() .apiKey(apiKey) .model(modelName) .messages(Arrays.asList(systemMsg, userMessage)) .maxTokens(2048) .incrementalOutput(true) // 开启增量输出(流式核心) .build(); // 4. 流式调用+推送结果 Flowable<MultiModalConversationResult> resultFlow = conv.streamCall(param); resultFlow.blockingForEach(item -> { try { if (item.getOutput() == null || item.getOutput().getChoices() == null || item.getOutput().getChoices().isEmpty()) { return; // 空结果跳过 } List<Map<String, Object>> content = item.getOutput().getChoices().get(0).getMessage().getContent(); if (content != null && !content.isEmpty()) { String text = content.get(0).get("text").toString(); // 推送单条流式数据(event名称:message) emitter.send(SseEmitter.event().name("message").data(text)); log.debug("推送流式数据:{}", text); } } catch (Exception e) { log.error("推送单条流式数据失败", e); handleEmitterError(emitter, "数据推送失败:" + e.getMessage()); } }); // 流式结束标记 emitter.send(SseEmitter.event().name("complete").data("流输出完成")); emitter.complete(); log.info("GUI-Plus流式调用完成"); } catch (IOException e) { log.error("读取本地图片失败", e); handleEmitterError(emitter, "读取本地图片失败:" + e.getMessage()); } catch (ApiException | NoApiKeyException | UploadFileException e) { log.error("GUI-Plus API调用失败", e); handleEmitterError(emitter, "API调用失败:" + e.getMessage()); } catch (IllegalArgumentException e) { log.error("请求参数异常", e); handleEmitterError(emitter, "参数错误:" + e.getMessage()); } catch (Exception e) { log.error("流式调用未知异常", e); handleEmitterError(emitter, "系统异常:" + e.getMessage()); } }).start(); return emitter; } /** * 工具方法:统一处理SSE异常 */ private void handleEmitterError(SseEmitter emitter, String errorMsg) { try { emitter.send(SseEmitter.event().name("error").data(errorMsg)); emitter.completeWithError(new RuntimeException(errorMsg)); } catch (Exception e) { log.error("处理SSE发射器异常失败", e); } } }

三、运行结果演示

由于篇幅限制,具体的分析我放在下一篇文章,给大家捋一捋

如果觉得这份修改实用、总结清晰,别忘了动动小手点个赞👍,再关注一下呀~ 后续还会分享更多 AI 接口封装、代码优化的干货技巧,一起解锁更多好用的功能,少踩坑多提效!🥰 你的支持就是我更新的最大动力,咱们下次分享再见呀~🌟

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/23 13:00:14

基于单片机的智能感应台灯设计

摘要 针对传统台灯手动开关、亮度固定导致的能源浪费与使用不便问题&#xff0c;设计了一款基于STM32F103单片机的智能感应台灯。该台灯融合人体感应、环境光检测与智能控制技术&#xff0c;可实现人来灯亮、人走灯灭、亮度自适应调节及定时关闭功能。测试表明&#xff0c;系统…

作者头像 李华
网站建设 2026/4/18 15:23:36

设计模式之-单例模式

1.创建一个单例类 class Singleton {constructor(name){this.namename;}static instance null;getName(){console.log(this.name);}static getInstance(name){if(!Singleton.instance){Singleton.instance new Singleton(name);}return Singleton.instance;} } const a Sing…

作者头像 李华
网站建设 2026/4/23 14:35:12

软考-系统集成项目管理工程师常考案例题:项目范围管理

一、范围管理计划用于指导如下过程和相关工作 1.制定项目范围说明书&#xff1b; 2.根据详细项目范围说明书创建WBS&#xff1b; 3.确定如何审批和维护范围基准&#xff1b; 4.正式验收已完成的项目可交付成果。 二、需求管理计划的主要内容 1.如何规划、跟踪和报告各种需求活动…

作者头像 李华
网站建设 2026/4/20 23:38:32

springcloud基于微服务架构企业员工工作流引擎的研究_pw80f4m7

文章目录 具体实现截图主要技术与实现手段关于我本系统开发思路java类核心代码部分展示结论源码lw获取/同行可拿货,招校园代理 &#xff1a;文章底部获取博主联系方式&#xff01; 具体实现截图 同行可拿货,招校园代理 springcloud_pw80f4m7 基于微服务架构企业员工工作流…

作者头像 李华
网站建设 2026/4/23 13:00:22

springcloud基于微服务架构的博客博文学习平台的设计与实现_0ov69h80

文章目录具体实现截图主要技术与实现手段关于我本系统开发思路java类核心代码部分展示结论源码lw获取/同行可拿货,招校园代理 &#xff1a;文章底部获取博主联系方式&#xff01;具体实现截图 同行可拿货,招校园代理 springcloud_ov69h80 基于微服务架构的博客博文学习平…

作者头像 李华
网站建设 2026/4/23 11:34:07

VMware ESXI 8.0安装vCenter 8.0

一、先在ESXI上创建一台windows虚拟机二、下载ISO VMware-VCSA-all-8.0.2-23504390.iso 下载以后&#xff0c;想办法放入上面开的windows虚拟机上三、安装VCSA按步骤安装即可&#xff0c;安装过程会自动在esxi上创建VCSA的虚拟机出现这个页面&#xff0c;就代表装完&#xff0c…

作者头像 李华