feat(inspection): 增加巡检点位语料配置功能

- 在 inspection_waypoint 表中新增 chinese_corpus 和 english_corpus 字段存储中英文语料
- 创建 InspectionWaypointCorpusConfigVO 用于巡检点位语料查询节点参数
- 实现 InspectionWaypointCorpusMapper 提供按检测项ID查询语料的数据库操作
- 开发 InspectionWaypointCorpusOperateService 处理语料类型识别和语料获取逻辑
- 添加语料类型支持包括中文/英文、zh-CN/en-US、zh/en或0/1等多种格式
- 实现语料验证机制确保巡检点位配置了相应语言语料
- 编写单元测试验证中英文语料获取和类型解析功能
- 为 VisionLocateOperateService 增加完整的测试用例覆盖安全方向校准和平面定位功能
This commit is contained in:
lixiaolong 2026-09-16 13:39:29 +08:00
parent 356999547c
commit 9cd3e4a5d6
7 changed files with 468 additions and 0 deletions

View File

@ -0,0 +1,87 @@
package com.cmvr.test.flow.runtime.operator.llm;
import com.alibaba.fastjson2.JSONObject;
import com.cmvr.common.exception.GlobalException;
import com.cmvr.test.enums.ActionEnum;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
import com.cmvr.test.mapper.InspectionWaypointCorpusMapper;
import com.cmvr.test.model.vo.inspection.InspectionWaypointCorpusConfigVO;
import com.cmvr.test.model.vo.inspection.InspectionWaypointCorpusVO;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.util.Locale;
/** 根据检测项和语料类型读取巡检点位配置的播放语料。 */
@Service
@RequiredArgsConstructor
public class InspectionWaypointCorpusOperateService implements LLMOperateService
{
private final InspectionWaypointCorpusMapper inspectionWaypointCorpusMapper;
@Override
public boolean supports(ActionEnum action)
{
return ActionEnum.INSPECTION_WAYPOINT_CORPUS.equals(action);
}
@Override
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message)
{
JSONObject input = message.getInputParams() == null ? new JSONObject() : message.getInputParams();
InspectionWaypointCorpusConfigVO config = input.to(InspectionWaypointCorpusConfigVO.class);
String detectItemId = StringUtils.trimToNull(config.getDetectItemId());
String type = StringUtils.trimToNull(config.getType());
if (detectItemId == null)
{
throw new GlobalException("检测项ID不能为空");
}
if (type == null)
{
throw new GlobalException("语料类型不能为空");
}
InspectionWaypointCorpusVO waypoint =
inspectionWaypointCorpusMapper.selectByDetectItemId(detectItemId);
if (waypoint == null)
{
throw new GlobalException("未找到检测项对应的巡检点位: " + detectItemId);
}
String corpus = resolveCorpus(waypoint, type);
if (StringUtils.isBlank(corpus))
{
throw new GlobalException("巡检点位未配置" + languageName(type) + "语料");
}
return TaskNodeExecuteResult.success(new JSONObject().fluentPut("corpus", corpus));
}
private String resolveCorpus(InspectionWaypointCorpusVO waypoint, String type)
{
return isChinese(type) ? waypoint.getChineseCorpus() : waypoint.getEnglishCorpus();
}
private String languageName(String type)
{
return isChinese(type) ? "中文" : "英文";
}
private boolean isChinese(String type)
{
String normalized = type.trim().toLowerCase(Locale.ROOT).replace('_', '-');
if ("0".equals(normalized) || "cn".equals(normalized) || "chinese".equals(normalized)
|| "中文".equals(normalized) || normalized.startsWith("zh"))
{
return true;
}
if ("1".equals(normalized) || "english".equals(normalized) || "英文".equals(normalized)
|| normalized.startsWith("en"))
{
return false;
}
throw new GlobalException("不支持的语料类型: " + type);
}
}

View File

@ -0,0 +1,13 @@
package com.cmvr.test.mapper;
import com.cmvr.test.model.vo.inspection.InspectionWaypointCorpusVO;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** 巡检点位语料查询Mapper。 */
public interface InspectionWaypointCorpusMapper
{
@Select("select chinese_corpus as chineseCorpus, english_corpus as englishCorpus "
+ "from inspection_waypoint where detect_item_id = #{detectItemId} limit 1")
InspectionWaypointCorpusVO selectByDetectItemId(@Param("detectItemId") String detectItemId);
}

View File

@ -0,0 +1,19 @@
package com.cmvr.test.model.vo.inspection;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/** 巡检点位语料查询工作流节点参数。 */
@Data
@ApiModel(value = "InspectionWaypointCorpusConfigVO",
description = "INSPECTION_WAYPOINT_CORPUS节点参数")
public class InspectionWaypointCorpusConfigVO
{
@ApiModelProperty(value = "点位关联的检测项ID", required = true)
private String detectItemId;
@ApiModelProperty(value = "语料类型,支持中文/英文、zh-CN/en-US、zh/en或0/1",
required = true, example = "zh-CN")
private String type;
}

View File

@ -0,0 +1,12 @@
package com.cmvr.test.model.vo.inspection;
import lombok.Data;
/** 巡检点位中英文语料查询结果。 */
@Data
public class InspectionWaypointCorpusVO
{
private String chineseCorpus;
private String englishCorpus;
}

View File

@ -0,0 +1,258 @@
package com.cmvr.test.flow.runtime.operator.edge;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.cmvr.llm.analysis.MediaAnalysisClient;
import com.cmvr.test.enums.ActionEnum;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
import com.cmvr.test.flow.runtime.operator.llm.LLMMediaAnalysisOperateService;
import com.cmvr.test.vision.domain.VisionCalibrationProfile;
import com.cmvr.test.vision.math.VisionCalibrationMath;
import org.junit.Test;
import java.lang.reflect.Proxy;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
public class VisionLocateOperateServiceTest {
@Test
public void calibratedSafeDirectionKeepsTheFinalFlangeOrientationStable() {
VisionLocateOperateService service = new VisionLocateOperateService(
null, null, null, null);
double[][] currentFlange = VisionCalibrationMath.fromCartesianPose(
cmvr.api.ArmCommand.CartesianPose.newBuilder()
.setRx(0.45).setRy(-0.3).setRz(0.7).build());
double[][] flangeToTcp = VisionCalibrationMath.fromCartesianPose(
cmvr.api.ArmCommand.CartesianPose.newBuilder()
.setX(0.07).setZ(0.01)
.setRx(0.08).setRy(-0.04).setRz(0.12).build());
double[][] currentTcp = VisionGeometry.multiply(currentFlange, flangeToTcp);
double[][] safeFlange = VisionCalibrationMath.fromCartesianPose(
cmvr.api.ArmCommand.CartesianPose.newBuilder()
.setRx(Math.PI).setRy(0.04).setRz(-0.2).build());
VisionCalibrationProfile toolProfile = new VisionCalibrationProfile();
toolProfile.setId("tool-profile-1");
toolProfile.setMetadataJson(new JSONObject()
.fluentPut("safeDirectionPose", matrixArray(safeFlange))
.toJSONString());
VisionLocateOperateService.StableToolOrientation orientation =
service.stableToolOrientation(currentTcp, flangeToTcp, toolProfile);
double[][] finalFlange = VisionGeometry.multiply(
orientation.tcpPose(), VisionGeometry.rigidInverse(flangeToTcp));
assertEquals("TOOL_CALIBRATION_SAFE_DIRECTION", orientation.source());
assertRotationEquals(safeFlange, finalFlange, 1e-10);
}
@Test
public void missingSafeDirectionPreservesCurrentTcpOrientation() {
VisionLocateOperateService service = new VisionLocateOperateService(
null, null, null, null);
double[][] currentTcp = VisionCalibrationMath.fromCartesianPose(
cmvr.api.ArmCommand.CartesianPose.newBuilder()
.setRx(0.2).setRy(0.4).setRz(-0.6).build());
double[][] identity = VisionCalibrationMath.identity();
VisionLocateOperateService.StableToolOrientation orientation =
service.stableToolOrientation(currentTcp, identity, null);
assertEquals("CURRENT_TCP", orientation.source());
assertRotationEquals(currentTcp, orientation.tcpPose(), 0.0);
}
@Test
public void calibratedPlaneMakesTargetZIndependentFromRgbdDepthNoise() {
VisionLocateOperateService service = new VisionLocateOperateService(
null, null, null, null);
double[][] identity = VisionCalibrationMath.identity();
double[][] safeFlange = VisionCalibrationMath.identity();
safeFlange[2][3] = 0.5;
VisionCalibrationProfile toolProfile = new VisionCalibrationProfile();
toolProfile.setId("tool-profile-plane");
toolProfile.setMetadataJson(new JSONObject()
.fluentPut("safeDirectionPose", matrixArray(safeFlange))
.fluentPut("planeNormalBase", new JSONArray()
.fluentAdd(0.0).fluentAdd(0.0).fluentAdd(1.0))
.toJSONString());
VisionLocateOperateService.StableToolOrientation orientation =
service.stableToolOrientation(identity, identity, toolProfile);
com.cmvr.edge.client.model.camera.EdgeCameraRgbdSnapshot snapshot =
com.cmvr.edge.client.model.camera.EdgeCameraRgbdSnapshot.builder()
.width(640).height(480)
.fx(600).fy(600).cx(320).cy(240)
.build();
VisionLocateOperateService.TargetPosition first = service.targetPosition(
identity, snapshot, 380, 240, new double[]{0.056, 0, 0.56},
List.of(), orientation, 0.0D);
VisionLocateOperateService.TargetPosition second = service.targetPosition(
identity, snapshot, 380, 240, new double[]{0.062, 0, 0.62},
List.of(), orientation, 0.0D);
assertEquals("TOOL_CALIBRATION_PLANE_OFFSET", first.source());
assertEquals(0.05, first.point()[0], 1e-12);
assertEquals(0.5, first.point()[2], 1e-12);
assertEquals(first.point()[2], second.point()[2], 0.0);
assertEquals(60.0, first.depthResidualMm(), 1e-9);
assertEquals(120.0, second.depthResidualMm(), 1e-9);
}
@Test
public void dynamicParallelPlaneTracksHeightButRejectsDepthOutliers() {
VisionLocateOperateService service = new VisionLocateOperateService(
null, null, null, null);
double[][] identity = VisionCalibrationMath.identity();
VisionLocateOperateService.StableToolOrientation orientation =
new VisionLocateOperateService.StableToolOrientation(
identity, "TEST", new double[]{0, 0, 0.5}, new double[]{0, 0, 1});
com.cmvr.edge.client.model.camera.EdgeCameraRgbdSnapshot snapshot =
com.cmvr.edge.client.model.camera.EdgeCameraRgbdSnapshot.builder()
.width(640).height(480)
.fx(600).fy(600).cx(320).cy(240)
.build();
List<double[]> lowerSamples = List.of(
pointAtDepth(0.619), pointAtDepth(0.620), pointAtDepth(0.621),
pointAtDepth(0.620), pointAtDepth(0.619), pointAtDepth(0.621),
pointAtDepth(0.620), pointAtDepth(0.620), pointAtDepth(0.619),
pointAtDepth(0.621), pointAtDepth(0.800));
List<double[]> higherSamples = List.of(
pointAtDepth(0.679), pointAtDepth(0.680), pointAtDepth(0.681),
pointAtDepth(0.680), pointAtDepth(0.679), pointAtDepth(0.681),
pointAtDepth(0.680), pointAtDepth(0.680), pointAtDepth(0.679),
pointAtDepth(0.681), pointAtDepth(0.900));
VisionLocateOperateService.TargetPosition lower = service.targetPosition(
identity, snapshot, 380, 240, new double[]{0.09, 0, 0.90},
lowerSamples, orientation, null);
VisionLocateOperateService.TargetPosition higher = service.targetPosition(
identity, snapshot, 380, 240, new double[]{0.05, 0, 0.50},
higherSamples, orientation, null);
assertEquals("RGBD_PARALLEL_PLANE", lower.source());
assertEquals(0.620, lower.point()[2], 1e-12);
assertEquals(0.680, higher.point()[2], 1e-12);
assertEquals(0.062, lower.point()[0], 1e-12);
assertEquals(0.068, higher.point()[0], 1e-12);
}
@Test
public void preciseLocateUsesTheSameImageAnalysisRequestAsGenericPreciseMatch() {
AtomicReference<JSONObject> captured = new AtomicReference<>();
MediaAnalysisClient client = (MediaAnalysisClient) Proxy.newProxyInstance(
MediaAnalysisClient.class.getClassLoader(),
new Class<?>[]{MediaAnalysisClient.class},
(proxy, method, args) -> {
if (!"analyze".equals(method.getName())) {
throw new UnsupportedOperationException(method.getName());
}
JSONObject request = (JSONObject) args[0];
captured.set(request);
return new JSONObject()
.fluentPut("status", "SUCCEEDED")
.fluentPut("analysisType", "IMAGE_ANALYSIS")
.fluentPut("profileCode", "common.image_analysis.v1")
.fluentPut("result", new JSONObject()
.fluentPut("matched", true)
.fluentPut("center", new JSONArray().fluentAdd(610).fluentAdd(238))
.fluentPut("matchScore", 0.8D));
}
);
LLMMediaAnalysisOperateService imageAnalysis =
new LLMMediaAnalysisOperateService(client, null);
imageAnalysis.execute(imageMessage(genericPreciseMatchInput()));
JSONObject genericRequest = captured.get();
VisionLocateOperateService visionLocate = new VisionLocateOperateService(
null, null, imageAnalysis, null);
visionLocate.analyze(visionMessage(), "https://files.example/scene.jpg",
"common.image_analysis.v1", "定位目标中心点", visualLocateInput(),
"REFERENCE_MATCH");
JSONObject visualRequest = captured.get();
assertEquals(genericRequest.getString("analysisType"), visualRequest.getString("analysisType"));
assertEquals(genericRequest.getString("profileCode"), visualRequest.getString("profileCode"));
assertEquals(genericRequest.getString("mediaUrl"), visualRequest.getString("mediaUrl"));
assertEquals(genericRequest.getJSONArray("mediaUrls"), visualRequest.getJSONArray("mediaUrls"));
assertEquals(genericRequest.getJSONObject("options"), visualRequest.getJSONObject("options"));
assertEquals(genericRequest.getJSONObject("context"), visualRequest.getJSONObject("context"));
}
private JSONObject genericPreciseMatchInput() {
return new JSONObject()
.fluentPut("profileCode", "common.image_analysis.v1")
.fluentPut("imageUrl", new JSONArray().fluentAdd("https://files.example/scene.jpg"))
.fluentPut("referenceImageUrl", "https://files.example/reference.png")
.fluentPut("prompt", "在大图中查找小图,返回原图像素坐标 [x1,y1,x2,y2]")
.fluentPut("analysisMethod", "REFERENCE_MATCH")
.fluentPut("analysisMode", "FAST")
.fluentPut("asynchronous", false)
.fluentPut("analysisTuning", new JSONObject()
.fluentPut("maxWidth", 896)
.fluentPut("maxImages", 12)
.fluentPut("maxOutputTokens", 384)
.fluentPut("matchThreshold", 0.48D)
.fluentPut("matchSelector", "BEST")
.fluentPut("matchIndex", 1)
.fluentPut("matchSort", "ROW_MAJOR")
.fluentPut("locateConfidenceThreshold", 0.45D));
}
private JSONObject visualLocateInput() {
return new JSONObject()
.fluentPut("referenceImageUrl", "https://files.example/reference.png")
.fluentPut("analysisMode", "ACCURATE")
.fluentPut("matchSelector", "BEST")
.fluentPut("matchIndex", "1")
.fluentPut("matchSort", "ROW_MAJOR")
.fluentPut("confidenceThreshold", "0.45");
}
private TaskNodeExecuteMessage imageMessage(JSONObject input) {
TaskNodeExecuteMessage message = baseMessage();
message.setAction(ActionEnum.IMAGE_ANALYZE);
message.setInputParams(input);
return message;
}
private TaskNodeExecuteMessage visionMessage() {
TaskNodeExecuteMessage message = baseMessage();
message.setAction(ActionEnum.VISION_LOCATE_TARGET);
message.setInputParams(visualLocateInput());
return message;
}
private TaskNodeExecuteMessage baseMessage() {
TaskNodeExecuteMessage message = new TaskNodeExecuteMessage();
message.setInstId("inst-1");
message.setTaskId("task-1");
message.setItemId("item-1");
message.setNodeId("node-1");
message.setTrial(true);
return message;
}
private JSONArray matrixArray(double[][] matrix) {
JSONArray values = new JSONArray();
for (double[] row : matrix) {
for (double value : row) values.add(value);
}
return values;
}
private double[] pointAtDepth(double depth) {
return new double[]{0, 0, depth};
}
private void assertRotationEquals(double[][] expected, double[][] actual, double tolerance) {
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 3; column++) {
assertEquals(expected[row][column], actual[row][column], tolerance);
}
}
}
}

View File

@ -0,0 +1,75 @@
package com.cmvr.test.flow.runtime.operator.llm;
import com.alibaba.fastjson2.JSONObject;
import com.cmvr.common.exception.GlobalException;
import com.cmvr.test.enums.ActionEnum;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
import com.cmvr.test.mapper.InspectionWaypointCorpusMapper;
import com.cmvr.test.model.vo.inspection.InspectionWaypointCorpusVO;
import org.junit.Test;
import java.lang.reflect.Proxy;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class InspectionWaypointCorpusOperateServiceTest
{
@Test
public void returnsChineseCorpus()
{
InspectionWaypointCorpusOperateService service = service("开始巡检", "Start inspection");
TaskNodeExecuteResult result = service.execute(message("zh-CN"));
assertTrue(result.isSuccess());
assertEquals("开始巡检", result.getOutputParams().getString("corpus"));
}
@Test
public void returnsEnglishCorpusForNumericType()
{
InspectionWaypointCorpusOperateService service = service("开始巡检", "Start inspection");
TaskNodeExecuteResult result = service.execute(message(1));
assertEquals("Start inspection", result.getOutputParams().getString("corpus"));
assertTrue(service.supports(ActionEnum.INSPECTION_WAYPOINT_CORPUS));
}
@Test(expected = GlobalException.class)
public void rejectsUnsupportedCorpusType()
{
service("开始巡检", "Start inspection").execute(message("fr-FR"));
}
private InspectionWaypointCorpusOperateService service(String chineseCorpus, String englishCorpus)
{
InspectionWaypointCorpusVO waypoint = new InspectionWaypointCorpusVO();
waypoint.setChineseCorpus(chineseCorpus);
waypoint.setEnglishCorpus(englishCorpus);
InspectionWaypointCorpusMapper mapper = (InspectionWaypointCorpusMapper) Proxy.newProxyInstance(
InspectionWaypointCorpusMapper.class.getClassLoader(),
new Class<?>[]{InspectionWaypointCorpusMapper.class},
(proxy, method, args) -> {
if ("selectByDetectItemId".equals(method.getName()))
{
assertEquals("detect-item-1", args[0]);
return waypoint;
}
throw new UnsupportedOperationException(method.getName());
});
return new InspectionWaypointCorpusOperateService(mapper);
}
private TaskNodeExecuteMessage message(Object type)
{
TaskNodeExecuteMessage message = new TaskNodeExecuteMessage();
message.setAction(ActionEnum.INSPECTION_WAYPOINT_CORPUS);
message.setInputParams(new JSONObject()
.fluentPut("detectItemId", "detect-item-1")
.fluentPut("type", type));
return message;
}
}

View File

@ -0,0 +1,4 @@
-- 巡检点位增加中英文可播放语料。
ALTER TABLE `inspection_waypoint`
ADD COLUMN `chinese_corpus` text DEFAULT NULL COMMENT '该点位可播放的中文语料' AFTER `detect_item_id`,
ADD COLUMN `english_corpus` text DEFAULT NULL COMMENT '该点位可播放的英文语料' AFTER `chinese_corpus`;