diff --git a/yolo26n.pt b/yolo26n.pt new file mode 100644 index 0000000..be48188 Binary files /dev/null and b/yolo26n.pt differ diff --git a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java index de330fd..cb3e18f 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java @@ -211,14 +211,14 @@ public class InvocableHandlerMethod extends HandlerMethod { catch (InvocationTargetException ex) { // Unwrap for HandlerExceptionResolvers ... Throwable targetException = ex.getTargetException(); - if (targetException instanceof RuntimeException runtimeException) { - throw runtimeException; + if (targetException instanceof RuntimeException) { + throw (RuntimeException) targetException; } - else if (targetException instanceof Error error) { - throw error; + else if (targetException instanceof Error) { + throw (Error) targetException; } - else if (targetException instanceof Exception exception) { - throw exception; + else if (targetException instanceof Exception) { + throw (Exception) targetException; } else { throw new IllegalStateException(formatInvokeError("Invocation failure", args), targetException); diff --git a/yudao-module-annotation/yudao-module-annotation-api/src/main/java/cn/iocoder/yudao/module/annotation/enums/ErrorCodeConstants.java b/yudao-module-annotation/yudao-module-annotation-api/src/main/java/cn/iocoder/yudao/module/annotation/enums/ErrorCodeConstants.java index 988b5fb..ba9cd10 100644 --- a/yudao-module-annotation/yudao-module-annotation-api/src/main/java/cn/iocoder/yudao/module/annotation/enums/ErrorCodeConstants.java +++ b/yudao-module-annotation/yudao-module-annotation-api/src/main/java/cn/iocoder/yudao/module/annotation/enums/ErrorCodeConstants.java @@ -15,4 +15,8 @@ public interface ErrorCodeConstants { // ========== 训练结果模块 1-008-002-000 ========== ErrorCode TRAIN_RESULT_NOT_EXISTS = new ErrorCode(1_008_002_000, "训练结果不存在"); + // ========== 数据集模块 1-008-003-000 ========== + ErrorCode DATASET_NOT_EXISTS = new ErrorCode(1_008_003_000, "数据集不存在"); + ErrorCode DATASET_PATH_NOT_SET = new ErrorCode(1_008_003_001, "数据集路径未设置"); + } \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/DatasController.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/DatasController.java index c28e759..a88c59a 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/DatasController.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/DatasController.java @@ -9,9 +9,12 @@ import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; import cn.iocoder.yudao.module.annotation.controller.admin.datas.vo.DatasEnhance; import cn.iocoder.yudao.module.annotation.controller.admin.datas.vo.DatasPageReqVO; import cn.iocoder.yudao.module.annotation.controller.admin.datas.vo.DatasRespVO; +import cn.iocoder.yudao.module.annotation.controller.admin.datas.vo.DatasRespVO.DatasetInfo; import cn.iocoder.yudao.module.annotation.controller.admin.datas.vo.DatasSaveReqVO; import cn.iocoder.yudao.module.annotation.dal.dataobject.datas.DatasDO; +import cn.iocoder.yudao.module.annotation.dal.dataobject.dataset.DatasetDO; import cn.iocoder.yudao.module.annotation.service.datas.DatasService; +import cn.iocoder.yudao.module.annotation.service.dataset.DatasetService; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -24,7 +27,10 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; @@ -38,6 +44,9 @@ public class DatasController { @Resource private DatasService datasService; + @Resource + private DatasetService datasetService; + @PostMapping("/create") @Operation(summary = "创建数据集管理") @PreAuthorize("@ss.hasPermission('annotation:datas:create')") @@ -69,26 +78,31 @@ public class DatasController { @PreAuthorize("@ss.hasPermission('annotation:datas:query')") public CommonResult getDatas(@RequestParam("id") Integer id) { DatasDO datas = datasService.getDatas(id); - return success(BeanUtils.toBean(datas, DatasRespVO.class)); - } - - // 刷新数据集 - - /** - * 刷新数据集 - * 步骤1.根据path,将 - * @param id - * @return - */ - @GetMapping("/refreshDatas") - @Operation(summary = "获得数据集管理") - @Parameter(name = "id", description = "编号", required = true, example = "1024") - public CommonResult refreshDatas(@RequestParam("id") Integer id) { - List list = datasService.list(new LambdaQueryWrapper().eq(DatasDO::getId, id)); - for (DatasDO datasDO : list){ - datasService.refreshDatas(datasDO); + DatasRespVO respVO = BeanUtils.toBean(datas, DatasRespVO.class); + + // 填充数据集列表信息 + if (datas.getDatasets() != null && !datas.getDatasets().isEmpty()) { + List datasetList = new ArrayList<>(); + String[] datasetIds = datas.getDatasets().split(","); + for (String datasetIdStr : datasetIds) { + try { + Integer datasetId = Integer.parseInt(datasetIdStr.trim()); + DatasetDO dataset = datasetService.getDataset(datasetId); + if (dataset != null) { + DatasetInfo info = new DatasetInfo(); + info.setId(dataset.getId()); + info.setName(dataset.getName()); + info.setPath(dataset.getPath()); + datasetList.add(info); + } + } catch (NumberFormatException e) { + // 忽略无效的数据集ID + } + } + respVO.setDatasetList(datasetList); } - return success(true); + + return success(respVO); } @PostMapping("/list") @@ -106,20 +120,47 @@ public class DatasController { List result = datasService.list(new LambdaQueryWrapper().eq(DatasDO::getStatus, pageReqVO.getStatus())); return success(result); } -///默认每张图片都生成一个新图片,循环遍历选择增强的类型 + @PostMapping("/enhance") @Operation(summary = "图片增强") -// @PreAuthorize("@ss.hasPermission('annotation:datas:query')") - public CommonResult enhance( @RequestBody DatasEnhance datasEnhance) { + @PreAuthorize("@ss.hasPermission('annotation:datas:query')") + public CommonResult enhance(@RequestBody DatasEnhance datasEnhance) { datasService.enhance(datasEnhance); return success(true); } + @GetMapping("/page") @Operation(summary = "获得数据集管理分页") @PreAuthorize("@ss.hasPermission('annotation:datas:query')") public CommonResult> getDatasPage(@Valid DatasPageReqVO pageReqVO) { PageResult pageResult = datasService.getDatasPage(pageReqVO); - return success(BeanUtils.toBean(pageResult, DatasRespVO.class)); + PageResult respPageResult = BeanUtils.toBean(pageResult, DatasRespVO.class); + + // 填充数据集列表信息 + for (DatasRespVO respVO : respPageResult.getList()) { + if (respVO.getDatasets() != null && !respVO.getDatasets().isEmpty()) { + List datasetList = new ArrayList<>(); + String[] datasetIds = respVO.getDatasets().split(","); + for (String datasetIdStr : datasetIds) { + try { + Integer datasetId = Integer.parseInt(datasetIdStr.trim()); + DatasetDO dataset = datasetService.getDataset(datasetId); + if (dataset != null) { + DatasetInfo info = new DatasetInfo(); + info.setId(dataset.getId()); + info.setName(dataset.getName()); + info.setPath(dataset.getPath()); + datasetList.add(info); + } + } catch (NumberFormatException e) { + // 忽略无效的数据集ID + } + } + respVO.setDatasetList(datasetList); + } + } + + return success(respPageResult); } @GetMapping("/export-excel") diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/vo/DatasRespVO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/vo/DatasRespVO.java index e025682..39fbff6 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/vo/DatasRespVO.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/vo/DatasRespVO.java @@ -1,12 +1,12 @@ package cn.iocoder.yudao.module.annotation.controller.admin.datas.vo; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.*; -import java.util.*; -import java.util.*; -import org.springframework.format.annotation.DateTimeFormat; +import lombok.Data; + import java.time.LocalDateTime; -import com.alibaba.excel.annotation.*; +import java.util.List; @Schema(description = "管理后台 - 数据集管理 Response VO") @Data @@ -29,10 +29,6 @@ public class DatasRespVO { @ExcelProperty("类型,还未标注,正在标注,正在训练,训练完成") private String status; - @Schema(description = "路径") - @ExcelProperty("路径") - private String path; - @Schema(description = "图片总数", example = "4380") @ExcelProperty("图片总数") private Long count; @@ -49,4 +45,44 @@ public class DatasRespVO { @ExcelProperty("创建时间") private LocalDateTime createTime; + @Schema(description = "数据集ID列表,逗号分隔") + private String datasets; + + @Schema(description = "数据集列表") + private List datasetList; + + @Schema(description = "训练比例") + private Integer trainRatio; + + @Schema(description = "验证比例") + private Integer valRatio; + + @Schema(description = "测试比例") + private Integer testRatio; + + @Schema(description = "训练次数") + private Integer epochs; + + @Schema(description = "批次") + private Double batch; + + @Schema(description = "图片大小") + private Integer imageSize; + + @Schema(description = "模型路径") + private String modelPath; + + @Data + @Schema(description = "数据集信息") + public static class DatasetInfo { + @Schema(description = "数据集ID") + private Integer id; + + @Schema(description = "数据集名称") + private String name; + + @Schema(description = "数据集路径") + private String path; + } + } \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/vo/DatasSaveReqVO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/vo/DatasSaveReqVO.java index 6ffa994..6c1e49d 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/vo/DatasSaveReqVO.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/datas/vo/DatasSaveReqVO.java @@ -1,9 +1,7 @@ package cn.iocoder.yudao.module.annotation.controller.admin.datas.vo; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.*; -import java.util.*; -import jakarta.validation.constraints.*; +import lombok.Data; @Schema(description = "管理后台 - 数据集管理新增/修改 Request VO") @Data @@ -21,9 +19,6 @@ public class DatasSaveReqVO { @Schema(description = "类型,还未标注,正在标注,正在训练,训练完成", example = "1") private String status; - @Schema(description = "路径") - private String path; - @Schema(description = "图片总数", example = "4380") private Long count; @@ -33,4 +28,28 @@ public class DatasSaveReqVO { @Schema(description = "进度") private Integer progress; + @Schema(description = "数据集ID列表,逗号分隔") + private String datasets; + + @Schema(description = "训练比例", example = "70") + private Integer trainRatio; + + @Schema(description = "验证比例", example = "20") + private Integer valRatio; + + @Schema(description = "测试比例", example = "10") + private Integer testRatio; + + @Schema(description = "训练次数", example = "100") + private Integer epochs; + + @Schema(description = "批次", example = "16.0") + private Double batch; + + @Schema(description = "图片大小", example = "640") + private Integer imageSize; + + @Schema(description = "模型路径") + private String modelPath; + } \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/DatasetController.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/DatasetController.java new file mode 100644 index 0000000..b043a90 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/DatasetController.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.annotation.controller.admin.dataset; + +import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; +import cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo.DatasetPageReqVO; +import cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo.DatasetRespVO; +import cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo.DatasetSaveReqVO; +import cn.iocoder.yudao.module.annotation.dal.dataobject.dataset.DatasetDO; +import cn.iocoder.yudao.module.annotation.service.dataset.DatasetService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.util.List; + +import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 识别数据集") +@RestController +@RequestMapping("/annotation/dataset") +@Validated +public class DatasetController { + + @Resource + private DatasetService datasetService; + + @PostMapping("/create") + @Operation(summary = "创建识别数据集") + @PreAuthorize("@ss.hasPermission('annotation:dataset:create')") + public CommonResult createDataset(@Valid @RequestBody DatasetSaveReqVO createReqVO) { + return success(datasetService.createDataset(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新识别数据集") + @PreAuthorize("@ss.hasPermission('annotation:dataset:update')") + public CommonResult updateDataset(@Valid @RequestBody DatasetSaveReqVO updateReqVO) { + datasetService.updateDataset(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除识别数据集") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('annotation:dataset:delete')") + public CommonResult deleteDataset(@RequestParam("id") Integer id) { + datasetService.deleteDataset(id); + return success(true); + } + + @PostMapping("/add-images") + @Operation(summary = "向数据集添加图片") + @PreAuthorize("@ss.hasPermission('annotation:dataset:update')") + public CommonResult> addImages(@RequestBody DatasetSaveReqVO updateReqVO) { + List imageUrls = datasetService.addImages(updateReqVO.getId(), updateReqVO.getFiles()); + return success(imageUrls); + } + + @GetMapping("/get") + @Operation(summary = "获得识别数据集") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('annotation:dataset:query')") + public CommonResult getDataset(@RequestParam("id") Integer id) { + DatasetDO dataset = datasetService.getDataset(id); + return success(BeanUtils.toBean(dataset, DatasetRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得识别数据集分页") + @PreAuthorize("@ss.hasPermission('annotation:dataset:query')") + public CommonResult> getDatasetPage(@Valid DatasetPageReqVO pageReqVO) { + PageResult pageResult = datasetService.getDatasetPage(pageReqVO); + + return success(BeanUtils.toBean(pageResult, DatasetRespVO.class)); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出识别数据集 Excel") + @PreAuthorize("@ss.hasPermission('annotation:dataset:export')") + @ApiAccessLog(operateType = EXPORT) + public void exportDatasetExcel(@Valid DatasetPageReqVO pageReqVO, + HttpServletResponse response) throws IOException { + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + List list = datasetService.getDatasetPage(pageReqVO).getList(); + // 导出 Excel + ExcelUtils.write(response, "识别数据集.xls", "数据", DatasetRespVO.class, + BeanUtils.toBean(list, DatasetRespVO.class)); + } + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/vo/DatasetPageReqVO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/vo/DatasetPageReqVO.java new file mode 100644 index 0000000..7cd0cbc --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/vo/DatasetPageReqVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo; + +import lombok.*; +import java.util.*; +import io.swagger.v3.oas.annotations.media.Schema; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 识别数据集分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class DatasetPageReqVO extends PageParam { + + @Schema(description = "名称", example = "芋艿") + private String name; + + @Schema(description = "路径,可以输入或者根据导入zip自动生成") + private String path; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/vo/DatasetRespVO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/vo/DatasetRespVO.java new file mode 100644 index 0000000..5b55d88 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/vo/DatasetRespVO.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import java.util.*; +import java.util.*; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; +import com.alibaba.excel.annotation.*; + +@Schema(description = "管理后台 - 识别数据集 Response VO") +@Data +@ExcelIgnoreUnannotated +public class DatasetRespVO { + + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED, example = "21637") + @ExcelProperty("id") + private Integer id; + + @Schema(description = "名称", example = "芋艿") + @ExcelProperty("名称") + private String name; + + @Schema(description = "路径,可以输入或者根据导入zip自动生成") + @ExcelProperty("路径,可以输入或者根据导入zip自动生成") + private String path; + + @Schema(description = "创建时间") + @ExcelProperty("创建时间") + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/vo/DatasetSaveReqVO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/vo/DatasetSaveReqVO.java new file mode 100644 index 0000000..6d8745f --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/dataset/vo/DatasetSaveReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import org.springframework.web.multipart.MultipartFile; +import jakarta.validation.constraints.*; +import java.util.*; + +@Schema(description = "管理后台 - 识别数据集新增/修改 Request VO") +@Data +public class DatasetSaveReqVO { + + @Schema(description = "id", example = "21637") + private Integer id; + + @Schema(description = "名称", example = "芋艿") + private String name; + + @Schema(description = "路径,可以输入或者根据导入zip自动生成") + private String path; + + @Schema(description = "上传的文件(zip压缩包或多张图片)") + private MultipartFile[] files; + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/MarkController.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/MarkController.java index d5f4116..0e1ab99 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/MarkController.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/MarkController.java @@ -1,12 +1,7 @@ package cn.iocoder.yudao.module.annotation.controller.admin.mark; -import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog; import cn.iocoder.yudao.framework.common.pojo.CommonResult; -import cn.iocoder.yudao.framework.common.pojo.PageParam; -import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; -import cn.iocoder.yudao.module.annotation.controller.admin.mark.vo.MarkPageReqVO; import cn.iocoder.yudao.module.annotation.controller.admin.mark.vo.MarkRespVO; import cn.iocoder.yudao.module.annotation.controller.admin.mark.vo.MarkSaveReqVO; import cn.iocoder.yudao.module.annotation.dal.dataobject.mark.MarkDO; @@ -18,16 +13,13 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; -import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import java.io.IOException; import java.util.List; -import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; @Tag(name = "管理后台 - 标注") @@ -95,25 +87,32 @@ public class MarkController { return success(BeanUtils.toBean(mark, MarkRespVO.class)); } - @GetMapping("/page") - @Operation(summary = "获得标注分页") - @PreAuthorize("@ss.hasPermission('annotation:mark:query')") - public CommonResult> getMarkPage(@Valid MarkPageReqVO pageReqVO) { - PageResult pageResult = markService.getMarkPage(pageReqVO); - return success(BeanUtils.toBean(pageResult, MarkRespVO.class)); + + @PutMapping("/update-file") + @Operation(summary = "更新标注文件") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('annotation:mark:update')") + public CommonResult updateAnnotationFile(@RequestParam("id") Integer id) { + markService.updateAnnotationFile(id); + return success(true); + } + + @DeleteMapping("/delete-file") + @Operation(summary = "删除标注文件") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('annotation:mark:delete')") + public CommonResult deleteAnnotationFile(@RequestParam("id") Integer id) { + markService.deleteAnnotationFile(id); + return success(true); } - @GetMapping("/export-excel") - @Operation(summary = "导出标注 Excel") - @PreAuthorize("@ss.hasPermission('annotation:mark:export')") - @ApiAccessLog(operateType = EXPORT) - public void exportMarkExcel(@Valid MarkPageReqVO pageReqVO, - HttpServletResponse response) throws IOException { - pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); - List list = markService.getMarkPage(pageReqVO).getList(); - // 导出 Excel - ExcelUtils.write(response, "标注.xls", "数据", MarkRespVO.class, - BeanUtils.toBean(list, MarkRespVO.class)); + @GetMapping("/read-file") + @Operation(summary = "读取标注文件内容") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('annotation:mark:query')") + public CommonResult readAnnotationFile(@RequestParam("id") Integer id) { + String content = markService.readAnnotationFile(id); + return success(content); } } \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/MarkInfoController.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/MarkInfoController.java index b0e3fc4..ce2a00f 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/MarkInfoController.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/MarkInfoController.java @@ -1,11 +1,16 @@ package cn.iocoder.yudao.module.annotation.controller.admin.mark; +import cn.hutool.core.io.FileUtil; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.module.annotation.controller.admin.mark.vo.MarkSaveReqVO; +import cn.iocoder.yudao.module.annotation.dal.dataobject.datas.DatasDO; +import cn.iocoder.yudao.module.annotation.dal.dataobject.mark.MarkDO; import cn.iocoder.yudao.module.annotation.dal.dataobject.mark.MarkInfoDO; +import cn.iocoder.yudao.module.annotation.dal.dataobject.types.TypesDO; import cn.iocoder.yudao.module.annotation.service.MarkInfo.AnnotationData; -import cn.iocoder.yudao.module.annotation.service.MarkInfo.MarkInfoService; +import cn.iocoder.yudao.module.annotation.service.datas.DatasService; import cn.iocoder.yudao.module.annotation.service.mark.MarkService; +import cn.iocoder.yudao.module.annotation.service.types.TypesService; import cn.iocoder.yudao.module.system.service.dict.DictDataService; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import io.swagger.v3.oas.annotations.Operation; @@ -13,68 +18,586 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import java.util.List; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileWriter; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.util.*; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; @Tag(name = "管理后台 - 标注详情") +@Slf4j @RestController @RequestMapping("/annotation/markInfo") @Validated public class MarkInfoController { - @Resource - private MarkInfoService markInfoService; @Resource private MarkService markService; - + @Resource + private DatasService datasService; + @Resource + private TypesService typesService; @Resource private DictDataService dictDataService; + @PostMapping("/create") @Operation(summary = "创建标注详情") @PreAuthorize("@ss.hasPermission('annotation:mark:create')") public CommonResult createMark(@Valid @RequestBody List createReqVO) { + if (createReqVO == null || createReqVO.isEmpty()) { + return success(0); + } - for (MarkInfoDO markInfoDO : createReqVO){ - markInfoDO.setCreator(null); - markInfoDO.setUpdater(null); - markInfoDO.setAnnotationDataString(markInfoDO.getAnnotationData().toString()); - markInfoService.saveOrUpdate(markInfoDO); + Integer markId = createReqVO.get(0).getMarkId(); + MarkDO mark = markService.getMark(markId); + if (mark == null) { + log.error("标注不存在: {}", markId); + return success(0); + } + + DatasDO datas = datasService.getDatas(mark.getDataId()); + if (datas == null) { + log.error("项目不存在: {}", mark.getDataId()); + return success(0); + } + + String projectType = datas.getType(); + + try { + // 直接更新标注文件 + if ("1".equals(projectType)) { + updateDetectionAnnotationFile(mark, createReqVO); + } else if ("2".equals(projectType)) { + updateClassificationAnnotation(mark, createReqVO.get(0), datas); + } + + // 更新标注状态 + markService.updateMark(new MarkSaveReqVO() + .setId(markId) + .setStatus(1)); + + return success(1); + } catch (Exception e) { + log.error("创建标注详情失败: markId={}", markId, e); + return success(0); } - markInfoService.remove(new QueryWrapper().eq("mark_id",createReqVO.get(0).getMarkId()).notIn("id",createReqVO.stream().map(MarkInfoDO::getId).toList())); - markService.updateMark(new MarkSaveReqVO() - .setId(createReqVO.get(0).getMarkId()) - .setStatus(1)); - return success(1); } @PostMapping("/list") @Operation(summary = "获得标注详情列表") @PreAuthorize("@ss.hasPermission('annotation:datas:query')") public CommonResult> list(@RequestParam("markId") Integer markId) { + MarkDO mark = markService.getMark(markId); + if (mark == null) { + log.error("标注不存在: {}", markId); + return success(new ArrayList<>()); + } + DatasDO datas = datasService.getDatas(mark.getDataId()); + if (datas == null) { + log.error("项目不存在: {}", mark.getDataId()); + return success(new ArrayList<>()); + } - List result = markInfoService.list(new QueryWrapper() - .eq("mark_id",markId)); - for (MarkInfoDO markInfoDO : result){ - markInfoDO.setAnnotationData(AnnotationData.fromString(markInfoDO.getAnnotationDataString())); + String projectType = datas.getType(); +// 在字典表的visual_type里面 + try { + if ("1".equals(projectType)) { + // 检测任务:读取标注文件并解析 + return success(readDetectionAnnotations(mark, datas)); + } else if ("2".equals(projectType)) { + // 分类任务:返回类别信息 + return success(readClassificationAnnotations(mark, datas)); + } + } catch (Exception e) { + log.error("读取标注详情失败: markId={}", markId, e); } - return success(result); - } + return success(new ArrayList<>()); + } @DeleteMapping("/delete") @Operation(summary = "删除标注详情") - @Parameter(name = "id", description = "编号", required = true) + @Parameter(name = "markId", description = "标注编号", required = true) + @Parameter(name = "annotationId", description = "标注ID(从1开始)", required = true) @PreAuthorize("@ss.hasPermission('annotation:mark:delete')") - public CommonResult deleteMark(@RequestParam("id") Integer id) { - markInfoService.deleteMarkInfo(id); - return success(true); + public CommonResult deleteMark(@RequestParam("markId") Integer markId, + @RequestParam("annotationId") Integer annotationId) { + MarkDO mark = markService.getMark(markId); + if (mark == null) { + log.error("标注不存在: {}", markId); + return success(false); + } + + DatasDO datas = datasService.getDatas(mark.getDataId()); + if (datas == null) { + log.error("项目不存在: {}", mark.getDataId()); + return success(false); + } + + String projectType = datas.getType(); + + try { + if ("1".equals(projectType)) { + // 检测任务:删除标注文件中的某一行 + deleteDetectionAnnotation(mark, annotationId); + } else if ("2".equals(projectType)) { + // 分类任务:不支持删除单个标注,需要重新分类 + log.warn("分类任务不支持删除单个标注"); + return success(false); + } + + return success(true); + } catch (Exception e) { + log.error("删除标注详情失败: markId={}, annotationId={}", markId, annotationId, e); + return success(false); + } + } + + /** + * 读取检测任务的标注文件 + */ + private List readDetectionAnnotations(MarkDO mark, DatasDO datas) throws Exception { + List result = new ArrayList<>(); + + // 获取标注文件路径 + String labelPath = mark.getLabelPath(); + if (labelPath == null || labelPath.isEmpty()) { + log.warn("标注文件路径为空: markId={}", mark.getId()); + return result; + } + + File labelFile = new File(labelPath); + if (!labelFile.exists()) { + log.warn("标注文件不存在: {}", labelPath); + return result; + } + + // 读取标注文件内容 + String content = FileUtil.readUtf8String(labelFile); + if (content == null || content.trim().isEmpty()) { + return result; + } + + // 获取图片尺寸 + String imagePath = mark.getImagePath(); + if (imagePath == null || imagePath.isEmpty()) { + log.warn("图片路径为空: markId={}", mark.getId()); + return result; + } + + File imageFile = new File(imagePath); + if (!imageFile.exists()) { + log.warn("图片文件不存在: {}", imagePath); + return result; + } + + BufferedImage image = ImageIO.read(imageFile); + int imageWidth = image.getWidth(); + int imageHeight = image.getHeight(); + + // 获取类别映射 + List typesList = typesService.list(new QueryWrapper() + .eq("data_id", mark.getDataId())); + typesList.sort(Comparator.comparing(TypesDO::getId)); + + // 解析标注文件 + String[] lines = content.split("\n"); + for (int i = 0; i < lines.length; i++) { + String line = lines[i].trim(); + if (line.isEmpty()) { + continue; + } + + try { + MarkInfoDO markInfo = parseYOLOLine(line, (long) (i + 1), imageWidth, imageHeight, typesList); + if (markInfo != null) { + markInfo.setMarkId(mark.getId()); + markInfo.setDataId(mark.getDataId()); + result.add(markInfo); + } + } catch (Exception e) { + log.error("解析YOLO标注行失败: {}", line, e); + } + } + + return result; + } + + /** + * 读取分类任务的标注 + */ + private List readClassificationAnnotations(MarkDO mark, DatasDO datas) { + List result = new ArrayList<>(); + + // 从路径中提取类别名称 + String path = mark.getImagePath(); + if (path == null || path.isEmpty()) { + return result; + } + + // 路径格式:split/classname/imagename.jpg 或 split/classify/classname/imagename.jpg + String[] parts = path.split("[\\\\/]"); + String className = null; + + for (int i = parts.length - 1; i >= 0; i--) { + if (parts[i].equals("train") || parts[i].equals("val") || parts[i].equals("test")) { + className = parts[i + 1]; + break; + } + } + + if (className == null) { + log.warn("无法从路径中提取类别: {}", path); + return result; + } + + // 查找对应的类别ID + List typesList = typesService.list(new QueryWrapper() + .eq("data_id", mark.getDataId()) + .eq("name", className)); + + if (!typesList.isEmpty()) { + MarkInfoDO markInfo = new MarkInfoDO(); + markInfo.setId(1L); + markInfo.setMarkId(mark.getId()); + markInfo.setDataId(mark.getDataId()); + markInfo.setClassId(typesList.get(0).getId()); + markInfo.setClassName(typesList.get(0).getName()); + + // 创建一个空的AnnotationData用于兼容 + AnnotationData emptyData = new AnnotationData(); + markInfo.setAnnotationData(emptyData); + markInfo.setAnnotationDataString(emptyData.toString()); + + result.add(markInfo); + } + + return result; + } + + /** + * 解析YOLO格式的一行标注 + */ + private MarkInfoDO parseYOLOLine(String line, Long id, int imageWidth, int imageHeight, + List typesList) { + String[] parts = line.split("\\s+"); + if (parts.length < 5) { + return null; + } + + try { + int classIndex = Integer.parseInt(parts[0]); + double centerX = Double.parseDouble(parts[1]); + double centerY = Double.parseDouble(parts[2]); + double width = Double.parseDouble(parts[3]); + double height = Double.parseDouble(parts[4]); + + // 转换为绝对坐标 + double x = centerX * imageWidth - (width * imageWidth) / 2; + double y = centerY * imageHeight - (height * imageHeight) / 2; + double w = width * imageWidth; + double h = height * imageHeight; + + // 获取类别ID + if (classIndex >= typesList.size()) { + log.warn("类别索引超出范围: {}", classIndex); + return null; + } + + Integer classId = typesList.get(classIndex).getId(); + + // 生成UUID + String uuid = UUID.randomUUID().toString(); + + // 创建AnnotationData + AnnotationData annotationData = new AnnotationData(); + annotationData.setId(uuid); + + // 获取类别颜色并添加到bodies + String color = typesList.get(classIndex).getColor(); + if (color != null && !color.isEmpty()) { + annotationData.setBodies(Arrays.asList(color)); + } else { + annotationData.setBodies(Arrays.asList("#409EFF")); // 默认颜色 + } + + AnnotationData.Target target = new AnnotationData.Target(); + target.setAnnotation(uuid); + + AnnotationData.Target.Selector selector = new AnnotationData.Target.Selector(); + selector.setType("RECTANGLE"); + + AnnotationData.Target.Selector.Geometry geometry = new AnnotationData.Target.Selector.Geometry(); + geometry.setX(x); + geometry.setY(y); + geometry.setW(w); + geometry.setH(h); + + // 计算bounds + AnnotationData.Target.Selector.Geometry.Bounds bounds = new AnnotationData.Target.Selector.Geometry.Bounds(); + bounds.setMinX(x); + bounds.setMinY(y); + bounds.setMaxX(x + w); + bounds.setMaxY(y + h); + geometry.setBounds(bounds); + + selector.setGeometry(geometry); + target.setSelector(selector); + + // 创建creator + AnnotationData.Target.Creator creator = new AnnotationData.Target.Creator(); + creator.setIsGuest(true); + creator.setId(UUID.randomUUID().toString().substring(0, 20)); // 生成20位随机ID + target.setCreator(creator); + + // 创建时间戳 + target.setCreated(new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + .format(new Date())); + + annotationData.setTarget(target); + + // 创建MarkInfoDO + MarkInfoDO markInfo = new MarkInfoDO(); + markInfo.setId(id); + markInfo.setClassId(classId); + markInfo.setClassName(typesList.get(classIndex).getName()); + markInfo.setCenterX(centerX); + markInfo.setCenterY(centerY); + markInfo.setWidth(width); + markInfo.setHeight(height); + markInfo.setAnnotationData(annotationData); + markInfo.setAnnotationDataString(annotationData.toString()); + + return markInfo; + } catch (Exception e) { + log.error("解析YOLO行失败: {}", line, e); + return null; + } + } + + /** + * 更新检测任务的标注文件 + */ + private void updateDetectionAnnotationFile(MarkDO mark, List markInfoList) throws Exception { + // 获取图片尺寸 + String imagePath = mark.getImagePath(); + if (imagePath == null || imagePath.isEmpty()) { + log.error("图片路径为空"); + return; + } + + File imageFile = new File(imagePath); + if (!imageFile.exists()) { + log.error("图片文件不存在: {}", imagePath); + return; + } + + BufferedImage image = ImageIO.read(imageFile); + int imageWidth = image.getWidth(); + int imageHeight = image.getHeight(); + + // 获取项目类型映射 + List typesList = typesService.list(new QueryWrapper() + .eq("data_id", mark.getDataId())); + typesList.sort(Comparator.comparing(TypesDO::getId)); + Map classIdMap = new HashMap<>(); + for (int i = 0; i < typesList.size(); i++) { + classIdMap.put(typesList.get(i).getId(), i); + } + + // 确定标注文件路径 + String labelPath = mark.getLabelPath(); + if (labelPath == null || labelPath.isEmpty()) { + log.error("标注文件路径为空"); + return; + } + + File labelFile = new File(labelPath); + File labelDir = labelFile.getParentFile(); + if (!labelDir.exists()) { + labelDir.mkdirs(); + } + + // 生成YOLO格式的标注文件 + try (PrintWriter writer = new PrintWriter(new FileWriter(labelFile))) { + for (MarkInfoDO markInfo : markInfoList) { + Integer classIndex = classIdMap.get(markInfo.getClassId()); + if (classIndex != null) { + AnnotationData annotationData = markInfo.getAnnotationData(); + if (annotationData != null) { + String yoloLine = convertToYOLOFormat(annotationData, classIndex, + imageWidth, imageHeight); + if (yoloLine != null) { + writer.println(yoloLine); + } + } + } + } + } + + log.info("更新检测标注文件成功: {}", labelFile.getAbsolutePath()); + } + + /** + * 更新分类任务的标注 + */ + private void updateClassificationAnnotation(MarkDO mark, MarkInfoDO markInfo, DatasDO datas) throws Exception { + if (markInfo == null) { + log.error("标注信息为空"); + return; + } + + // 获取类别名称 + TypesDO type = typesService.getById(markInfo.getClassId()); + if (type == null) { + log.error("类别不存在: classId={}", markInfo.getClassId()); + return; + } + + String className = type.getName(); + + // 获取项目路径 + + // 源图片路径 + String sourceImagePath = mark.getImagePath(); + if (sourceImagePath == null || sourceImagePath.isEmpty()) { + log.error("源图片路径为空"); + return; + } + + File sourceImage = new File(sourceImagePath); + if (!sourceImage.exists()) { + log.error("源图片文件不存在: {}", sourceImagePath); + return; + } +// 源类型 + + // 路径格式:split/classname/imagename.jpg 或 split/classify/classname/imagename.jpg + String[] parts = mark.getImagePath().split("[\\\\/]"); + String splitType = null; + + for (int i = parts.length - 1; i >= 0; i--) { + if (parts[i].equals("train") || parts[i].equals("val") || parts[i].equals("test")) { + splitType = parts[i + 1]; + break; + } + } + + + // 确定目标目录(split/classname/) + String targetDirPath = mark.getImagePath().replace(splitType, markInfo.getClassName()); + String relativePath = mark.getPath().replace(splitType, markInfo.getClassName()); + File targetDir = new File(targetDirPath); + if (!targetDir.exists()) { + targetDir.mkdirs(); + } + + // 移动图片到目标目录 + String imageFileName = sourceImage.getName(); + File targetImage = new File(targetDir, imageFileName); + Files.move(sourceImage.toPath(), targetImage.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + markService.updateMark(new MarkSaveReqVO() + .setId(mark.getId()) + .setPath(relativePath) + .setImagePath(targetDirPath)); + + log.info("更新分类标注成功: {}", targetImage.getAbsolutePath()); + } + + /** + * 删除检测任务的某条标注 + */ + private void deleteDetectionAnnotation(MarkDO mark, Integer annotationId) throws Exception { + String labelPath = mark.getLabelPath(); + if (labelPath == null || labelPath.isEmpty()) { + log.error("标注文件路径为空"); + return; + } + + File labelFile = new File(labelPath); + if (!labelFile.exists()) { + log.warn("标注文件不存在: {}", labelPath); + return; + } + + // 读取标注文件内容 + List lines = FileUtil.readUtf8Lines(labelFile); + if (annotationId < 1 || annotationId > lines.size()) { + log.error("标注ID超出范围: {}", annotationId); + return; + } + + // 删除指定行(annotationId从1开始,所以要减1) + lines.remove(annotationId - 1); + + // 写回文件 + FileUtil.writeUtf8Lines(lines, labelFile); + + log.info("删除检测标注成功: markId={}, annotationId={}", mark.getId(), annotationId); + } + + /** + * 确定分割类型(train/val/test) + */ + private String determineSplitType(String path) { + if (path == null || path.isEmpty()) { + return null; + } + + String[] parts = path.split("/"); + for (String part : parts) { + if (part.equals("train") || part.equals("val") || part.equals("test")) { + return part; + } + } + + return "train"; // 默认train + } + + /** + * 转换为YOLO格式 + */ + private String convertToYOLOFormat(AnnotationData annotationData, Integer classIndex, + int imageWidth, int imageHeight) { + try { + AnnotationData.Target.Selector.Geometry geometry = annotationData.getTarget().getSelector().getGeometry(); + + // 获取边界框信息 + double x = geometry.getX() != null ? geometry.getX() : 0; + double y = geometry.getY() != null ? geometry.getY() : 0; + double w = geometry.getW() != null ? geometry.getW() : 0; + double h = geometry.getH() != null ? geometry.getH() : 0; + + // 转换为YOLO格式(相对坐标和宽高) + double centerX = (x + w / 2) / imageWidth; + double centerY = (y + h / 2) / imageHeight; + double width = w / imageWidth; + double height = h / imageHeight; + + // 确保坐标在0-1范围内 + centerX = Math.max(0, Math.min(1, centerX)); + centerY = Math.max(0, Math.min(1, centerY)); + width = Math.max(0, Math.min(1, width)); + height = Math.max(0, Math.min(1, height)); + + return String.format("%d %.6f %.6f %.6f %.6f", classIndex, centerX, centerY, width, height); + + } catch (Exception e) { + log.error("转换YOLO格式失败", e); + return null; + } } -} \ No newline at end of file +} diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/vo/MarkSaveReqVO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/vo/MarkSaveReqVO.java index 8dd9cca..694341f 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/vo/MarkSaveReqVO.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/mark/vo/MarkSaveReqVO.java @@ -9,5 +9,8 @@ public class MarkSaveReqVO { private Integer dataId; private Integer id; private Integer status; + private String path; + private String imagePath; + private String labelPath; } \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/ModelController.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/ModelController.java new file mode 100644 index 0000000..55f3c78 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/ModelController.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.annotation.controller.admin.model; + +import org.springframework.web.bind.annotation.*; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.security.access.prepost.PreAuthorize; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Operation; + +import jakarta.validation.constraints.*; +import jakarta.validation.*; +import jakarta.servlet.http.*; +import java.util.*; +import java.io.IOException; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; + +import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog; +import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.*; + +import cn.iocoder.yudao.module.annotation.controller.admin.model.vo.*; +import cn.iocoder.yudao.module.annotation.dal.dataobject.model.ModelDO; +import cn.iocoder.yudao.module.annotation.service.model.ModelService; + +@Tag(name = "管理后台 - 模型管理") +@RestController +@RequestMapping("/annotation/model") +@Validated +public class ModelController { + + @Resource + private ModelService modelService; + + @PostMapping("/create") + @Operation(summary = "创建模型管理") + @PreAuthorize("@ss.hasPermission('annotation:model:create')") + public CommonResult createModel(@Valid @RequestBody ModelSaveReqVO createReqVO) { + return success(modelService.createModel(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新模型管理") + @PreAuthorize("@ss.hasPermission('annotation:model:update')") + public CommonResult updateModel(@Valid @RequestBody ModelSaveReqVO updateReqVO) { + modelService.updateModel(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除模型管理") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('annotation:model:delete')") + public CommonResult deleteModel(@RequestParam("id") Integer id) { + modelService.deleteModel(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得模型管理") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('annotation:model:query')") + public CommonResult getModel(@RequestParam("id") Integer id) { + ModelDO model = modelService.getModel(id); + return success(BeanUtils.toBean(model, ModelRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得模型管理分页") + @PreAuthorize("@ss.hasPermission('annotation:model:query')") + public CommonResult> getModelPage(@Valid ModelPageReqVO pageReqVO) { + PageResult pageResult = modelService.getModelPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, ModelRespVO.class)); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出模型管理 Excel") + @PreAuthorize("@ss.hasPermission('annotation:model:export')") + @ApiAccessLog(operateType = EXPORT) + public void exportModelExcel(@Valid ModelPageReqVO pageReqVO, + HttpServletResponse response) throws IOException { + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + List list = modelService.getModelPage(pageReqVO).getList(); + // 导出 Excel + ExcelUtils.write(response, "模型管理.xls", "数据", ModelRespVO.class, + BeanUtils.toBean(list, ModelRespVO.class)); + } + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/vo/ModelPageReqVO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/vo/ModelPageReqVO.java new file mode 100644 index 0000000..3b348aa --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/vo/ModelPageReqVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.annotation.controller.admin.model.vo; + +import lombok.*; +import java.util.*; +import io.swagger.v3.oas.annotations.media.Schema; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 模型管理分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class ModelPageReqVO extends PageParam { + + @Schema(description = "名称", example = "王五") + private String name; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + + @Schema(description = "类型", example = "1") + private String type; + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/vo/ModelRespVO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/vo/ModelRespVO.java new file mode 100644 index 0000000..b854704 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/vo/ModelRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.annotation.controller.admin.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import java.util.*; +import java.util.*; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; +import com.alibaba.excel.annotation.*; +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.framework.excel.core.convert.DictConvert; + +@Schema(description = "管理后台 - 模型管理 Response VO") +@Data +@ExcelIgnoreUnannotated +public class ModelRespVO { + + @Schema(description = "名称", example = "王五") + @ExcelProperty("名称") + private String name; + + @Schema(description = "创建时间") + @ExcelProperty("创建时间") + private LocalDateTime createTime; + + @Schema(description = "数据集列表") + @ExcelProperty("数据集列表") + private String datasets; + + @Schema(description = "类型", example = "1") + @ExcelProperty(value = "类型", converter = DictConvert.class) + @DictFormat("visual_type") // TODO 代码优化:建议设置到对应的 DictTypeConstants 枚举类中 + private String type; + + @Schema(description = "项目id", example = "31774") + @ExcelProperty("项目id") + private Integer dataId; + + @Schema(description = "采用数据集") + @ExcelProperty("采用数据集") + private String datasetIds; + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/vo/ModelSaveReqVO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/vo/ModelSaveReqVO.java new file mode 100644 index 0000000..baae4c7 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/controller/admin/model/vo/ModelSaveReqVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.annotation.controller.admin.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - 模型管理新增/修改 Request VO") +@Data +public class ModelSaveReqVO { + + @Schema(description = "id", example = "1") + private Integer id; + + @Schema(description = "名称", example = "王五") + private String name; + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/datas/DatasDO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/datas/DatasDO.java index 73ea825..4e9e2e8 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/datas/DatasDO.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/datas/DatasDO.java @@ -54,5 +54,38 @@ public class DatasDO extends BaseDO { * 进度 */ private double progress; + /** + * 数据集 , 逗号分隔 + */ + private String datasets; + /** + * 训练比例 + */ + private Integer trainRatio; + /** + * 验证比例 + */ + private Integer valRatio; + /** + * 测试比例 + */ + private Integer testRatio; + /** + * 训练次数 + */ + private Integer epochs; + /** + * 批次 + */ + private Double batch = -1.0; + /** + * 图片大小 + */ + private Integer imageSize; + /** + * 模型路径 + */ + private String modelPath; + } \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/dataset/DatasetDO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/dataset/DatasetDO.java new file mode 100644 index 0000000..2ea5c7d --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/dataset/DatasetDO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.annotation.dal.dataobject.dataset; + +import lombok.*; +import java.util.*; +import java.time.LocalDateTime; +import java.time.LocalDateTime; +import com.baomidou.mybatisplus.annotation.*; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; + +/** + * 识别数据集 DO + * + * @author 管理员 + */ +@TableName("annotation_dataset") +@KeySequence("annotation_dataset_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DatasetDO extends BaseDO { + + /** + * id + */ + @TableId + private Integer id; + /** + * 名称 + */ + private String name; + /** + * 路径,可以输入或者根据导入zip自动生成 + */ + private String path; + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/mark/MarkDO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/mark/MarkDO.java index aca1e10..bcdf278 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/mark/MarkDO.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/mark/MarkDO.java @@ -34,16 +34,10 @@ public class MarkDO extends BaseDO { * 项目id */ private Integer dataId; - /** - * 标注,类型是[{对象}],class_id,center_x,center_y,width,height,polygon_points,angle - */ -// 在 MarkDO 中使用 - /** - * 标注,类型是[{对象}],class_id,center_x,center_y,width,height,polygon_points,angle - */ -// @TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class) -// private List annotation; + private String imagePath; + + private String labelPath; /** * 1.标注完成,0,未标注 */ diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/model/ModelDO.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/model/ModelDO.java new file mode 100644 index 0000000..0b5b3e8 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/dataobject/model/ModelDO.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.annotation.dal.dataobject.model; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; +import org.glassfish.jaxb.core.v2.TODO; + +/** + * 模型管理 DO + * + * @author 管理员 + */ +@TableName("annotation_model") +@KeySequence("annotation_model_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ModelDO extends BaseDO { + + /** + * id + */ + @TableId + private Integer id; + /** + * 名称 + */ + private String name; + /** + * 数据集列表 + */ + private String datasets; + /** + * 类型 + * + * 枚举 {@link TODO visual_type 对应的类} + */ + private String type; + /** + * 项目id + */ + private Integer dataId; + /** + * 采用数据集 + */ + private String datasetIds; + /** + * 是否默认 + */ + private Integer isDefault; + /** + * 模型路径 + */ + private String path; + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/mysql/dataset/DatasetMapper.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/mysql/dataset/DatasetMapper.java new file mode 100644 index 0000000..1a2089e --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/mysql/dataset/DatasetMapper.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.annotation.dal.mysql.dataset; + +import java.util.*; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.annotation.dal.dataobject.dataset.DatasetDO; +import org.apache.ibatis.annotations.Mapper; +import cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo.*; + +/** + * 识别数据集 Mapper + * + * @author 管理员 + */ +@Mapper +public interface DatasetMapper extends BaseMapperX { + + default PageResult selectPage(DatasetPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(DatasetDO::getName, reqVO.getName()) + .eqIfPresent(DatasetDO::getPath, reqVO.getPath()) + .betweenIfPresent(DatasetDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(DatasetDO::getId)); + } + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/mysql/model/ModelMapper.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/mysql/model/ModelMapper.java new file mode 100644 index 0000000..a97ea8b --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/dal/mysql/model/ModelMapper.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.annotation.dal.mysql.model; + +import java.util.*; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.annotation.dal.dataobject.model.ModelDO; +import org.apache.ibatis.annotations.Mapper; +import cn.iocoder.yudao.module.annotation.controller.admin.model.vo.*; + +/** + * 模型管理 Mapper + * + * @author 管理员 + */ +@Mapper +public interface ModelMapper extends BaseMapperX { + + default PageResult selectPage(ModelPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(ModelDO::getName, reqVO.getName()) + .betweenIfPresent(ModelDO::getCreateTime, reqVO.getCreateTime()) + .eqIfPresent(ModelDO::getType, reqVO.getType()) + .orderByDesc(ModelDO::getId)); + } + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/enums/ErrorCodeConstants.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/enums/ErrorCodeConstants.java new file mode 100644 index 0000000..9e63874 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/enums/ErrorCodeConstants.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.annotation.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * annotation 错误码枚举类 + * + * annotation 系统,使用 1-008-000-000 段 + */ +public interface ErrorCodeConstants { + + // ========== 训练信息模块 1-008-001-000 ========== + ErrorCode TRAIN_INFO_NOT_EXISTS = new ErrorCode(1_008_001_000, "训练信息不存在"); + + // ========== 训练结果模块 1-008-002-000 ========== + ErrorCode TRAIN_RESULT_NOT_EXISTS = new ErrorCode(1_008_002_000, "训练结果不存在"); + + // ========== 数据集模块 1-008-003-000 ========== + ErrorCode DATASET_NOT_EXISTS = new ErrorCode(1_008_003_000, "数据集不存在"); + ErrorCode DATASET_PATH_NOT_SET = new ErrorCode(1_008_003_001, "数据集路径未设置"); + ErrorCode MODEL_NOT_EXISTS = new ErrorCode(1_008_003_001, "模型不存在"); + + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/datas/DatasServiceImpl.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/datas/DatasServiceImpl.java index 01e7804..8334c99 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/datas/DatasServiceImpl.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/datas/DatasServiceImpl.java @@ -5,26 +5,35 @@ import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.annotation.controller.admin.datas.vo.DatasEnhance; import cn.iocoder.yudao.module.annotation.controller.admin.datas.vo.DatasPageReqVO; import cn.iocoder.yudao.module.annotation.controller.admin.datas.vo.DatasSaveReqVO; +import cn.iocoder.yudao.module.annotation.controller.admin.types.vo.TypesSaveReqVO; import cn.iocoder.yudao.module.annotation.dal.dataobject.datas.DatasDO; +import cn.iocoder.yudao.module.annotation.dal.dataobject.dataset.DatasetDO; import cn.iocoder.yudao.module.annotation.dal.dataobject.mark.MarkDO; import cn.iocoder.yudao.module.annotation.dal.dataobject.mark.MarkInfoDO; import cn.iocoder.yudao.module.annotation.dal.mysql.datas.DatasMapper; import cn.iocoder.yudao.module.annotation.dal.mysql.mark.MarkInfoMapper; import cn.iocoder.yudao.module.annotation.service.ImageService; +import cn.iocoder.yudao.module.annotation.service.dataset.DatasetService; import cn.iocoder.yudao.module.annotation.service.mark.MarkService; +import cn.iocoder.yudao.module.annotation.service.types.TypesService; import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO; import cn.iocoder.yudao.module.system.service.dict.DictDataService; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; //import static cn.iocoder.yudao.module.annotation.enums.ErrorCodeConstants.*; /** @@ -32,6 +41,7 @@ import java.util.stream.Collectors; * * @author 管理员 */ +@Slf4j @Service @Validated public class DatasServiceImpl extends ServiceImpl implements DatasService{ @@ -44,89 +54,43 @@ public class DatasServiceImpl extends ServiceImpl impleme @Resource private DictDataService dictDataService; - /** - * 递归检查路径是否存在,并获取其中所有图片文件名称(包括子目录中的图片) - * @param path 要检查的路径字符串 - * @return 图片文件名称列表 - */ - public List getImageNamesFromPath(String superiorPath) { - DictDataDO basePath = dictDataService.parseDictData("visual_annotation_conf","base_path"); - String path = basePath.getValue() + superiorPath; - List imageNames = new ArrayList<>(); + @Resource + private DatasetService datasetService; - // 检查路径是否为空 - if (path == null || path.isEmpty()) { - return imageNames; - } + @Resource + private MarkInfoMapper markInfoMapper; - File directory = new File(path); - File rootDirectory = directory; // 保存根目录引用 + @Resource + private ImageService imageService; - // 检查路径是否存在且为目录 + /** + * 递归获取指定目录下的所有图片文件路径 + * @param directory 目录 + * @return 图片文件路径列表 + */ + private List getAllImageFiles(File directory) { + List imageFiles = new ArrayList<>(); if (!directory.exists() || !directory.isDirectory()) { - return imageNames; + return imageFiles; } - // 递归遍历目录,传递根目录用于计算相对路径 - traverseDirectory(directory, rootDirectory, imageNames); - imageNames = imageNames.stream() - .map(v->{return superiorPath+"/"+v;}) - .collect(Collectors.toList()); - - return imageNames; - } - - /** - * 递归遍历目录查找图片文件 - * @param directory 要遍历的目录 - * @param rootDirectory 根目录 - * @param imageNames 图片文件名称收集列表 - */ - private void traverseDirectory(File directory, File rootDirectory, List imageNames) { File[] files = directory.listFiles(); if (files != null) { for (File file : files) { if (file.isDirectory()) { - // 递归处理子目录,保持根目录不变 - traverseDirectory(file, rootDirectory, imageNames); + imageFiles.addAll(getAllImageFiles(file)); } else if (isImageFile(file)) { - // 添加图片文件(包含相对于根目录的路径) - String relativePath = getRelativePath(file, rootDirectory); - imageNames.add(relativePath); + imageFiles.add(file.getAbsolutePath()); } } } - } - - - /** - * 获取文件相对于根目录的路径 - * @param file 文件 - * @param rootDirectory 根目录 - * @return 相对路径 - */ - private String getRelativePath(File file, File rootDirectory) { - // 使用 Path API 来正确计算相对路径 - try { - return rootDirectory.toPath().relativize(file.toPath()).toString(); - } catch (IllegalArgumentException e) { - // 如果无法计算相对路径,则返回文件名 - return file.getName(); - } - } - - - - public static void main(String[] args) { - DatasServiceImpl datasService = new DatasServiceImpl(); - List imageNames = datasService.getImageNamesFromPath("D:\\PycharmProjects\\yolo\\runs\\detect\\11"); - System.out.println(imageNames); + return imageFiles; } /** * 判断文件是否为图片文件 - * @param file 要判断的文件 + * @param file 文件 * @return 是否为图片文件 */ private boolean isImageFile(File file) { @@ -142,22 +106,351 @@ public class DatasServiceImpl extends ServiceImpl impleme fileName.endsWith(".bmp") || fileName.endsWith(".webp"); } + + /** + * 复制文件 + * @param source 源文件 + * @param dest 目标文件 + */ + private void copyFile(File source, File dest) throws IOException { + Files.copy(source.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + + /** + * 处理数据集,根据任务类型生成相应的数据集结构 + * @param datasDO 数据对象 + * @return 生成的图片信息列表,每个Map包含:0=url路径, 1=文件路径, 2=标注路径(分类没有) + */ + private List> processDatasets(DatasDO datasDO) { + List> imagePaths = new ArrayList<>(); + + if (datasDO.getDatasets() == null || datasDO.getDatasets().isEmpty()) { + return imagePaths; + } + + // 获取字典配置 + Map baseConf = dictDataService.getDictDataList("base_conf"); + DictDataDO basePathDO = baseConf.get("base_path"); + DictDataDO dataApiPathDO = baseConf.get("data_api_path"); + + if (basePathDO == null) { + log.error("base_conf.base_path 配置不存在"); + return imagePaths; + } + + String basePath = basePathDO.getValue(); + String dataApiPath = dataApiPathDO != null ? dataApiPathDO.getValue() : ""; + + // 创建YOLO训练目录 + String yoloProjectDir = basePath + File.separator + "yolo" + File.separator + datasDO.getId(); + File yoloDir = new File(yoloProjectDir); + if (!yoloDir.exists()) { + yoloDir.mkdirs(); + } + + // 根据任务类型创建目录结构 + String projectType = datasDO.getType(); +// 在字典表的visual_type里面 + if ("2".equals(projectType)) { + // 分类任务:data/train/class1/, data/val/class1/ + processClassificationDatasets(datasDO, yoloDir, basePath, dataApiPath, imagePaths); + } else if ("1".equals(projectType)) { + // 检测任务:data/images/train/, data/labels/train/ + processDetectionDatasets(datasDO, yoloDir, basePath, dataApiPath, imagePaths); + } else { + log.error("未知的任务类型: {}", projectType); + } + + return imagePaths; + } + @Resource + TypesService typesService; + + /** + * 处理分类任务数据集 + * 目录结构: + * data/ + * ├── train/ + * │ ├── class1/ + * │ └── class2/ + * └── val/ + * ├── class1/ + * └── class2/ + */ + private void processClassificationDatasets(DatasDO datasDO, File yoloDir, String basePath, + String dataApiPath, List> imagePaths) { +// 将所有图片保存到默认路径下,并且创建默认分类 + TypesSaveReqVO typesSaveReqVO = new TypesSaveReqVO(); + typesSaveReqVO.setDataId(datasDO.getId()); + typesSaveReqVO.setName("default"); + typesSaveReqVO.setColor("#FF6B6B"); + typesService.createTypes(typesSaveReqVO); + // 解析数据集ID + String[] datasetIds = datasDO.getDatasets().split(","); + List datasetList = new ArrayList<>(); + for (String datasetIdStr : datasetIds) { + try { + Integer datasetId = Integer.parseInt(datasetIdStr.trim()); + DatasetDO dataset = datasetService.getDataset(datasetId); + if (dataset != null) { + datasetList.add(dataset); + } + } catch (NumberFormatException e) { + log.warn("无效的数据集ID: {}", datasetIdStr); + } + } + + // 收集所有图片 + List allImages = new ArrayList<>(); + for (DatasetDO dataset : datasetList) { + String datasetPath = dataset.getPath(); + if (datasetPath != null && !datasetPath.isEmpty()) { + File datasetDir = new File(datasetPath); + if (datasetDir.exists()) { + allImages.addAll(getAllImageFiles(datasetDir)); + } + } + } + + // 根据比例分配图片 + int totalImages = allImages.size(); + int trainCount = (int) (totalImages * datasDO.getTrainRatio() / 100.0); + int valCount = (int) (totalImages * datasDO.getValRatio() / 100.0); + int testCount = totalImages - trainCount - valCount; + + Collections.shuffle(allImages); + + // 创建目录结构 + File dataDir = yoloDir; + File trainDir = new File(dataDir, "train"); + File valDir = new File(dataDir, "val"); + + if (!trainDir.exists()) trainDir.mkdirs(); + if (!valDir.exists()) valDir.mkdirs(); + + // 创建默认类别目录(由于分类任务需要类别,这里先创建一个默认类别) + File defaultClassDir = new File(trainDir, "default"); + if (!defaultClassDir.exists()) defaultClassDir.mkdirs(); + + File defaultClassValDir = new File(valDir, "default"); + if (!defaultClassValDir.exists()) defaultClassValDir.mkdirs(); + + // 处理训练集 + for (int i = 0; i < trainCount; i++) { + String imagePath = allImages.get(i); + processClassificationImage(imagePath, defaultClassDir, basePath, dataApiPath, + datasDO.getId(), "train", "default", imagePaths); + } + + // 处理验证集 + for (int i = trainCount; i < trainCount + valCount; i++) { + String imagePath = allImages.get(i); + processClassificationImage(imagePath, defaultClassValDir, basePath, dataApiPath, + datasDO.getId(), "val", "default", imagePaths); + } + + // 测试集(合并到验证集) + for (int i = trainCount + valCount; i < totalImages; i++) { + String imagePath = allImages.get(i); + processClassificationImage(imagePath, defaultClassValDir, basePath, dataApiPath, + datasDO.getId(), "val", "default", imagePaths); + } + } + + /** + * 处理单张分类图片 + */ + private void processClassificationImage(String imagePath, File destDir, String basePath, + String dataApiPath, Integer projectId, String split, + String className, List> imagePaths) { + File sourceFile = new File(imagePath); + File destFile = new File(destDir, sourceFile.getName()); + + try { + copyFile(sourceFile, destFile); + + // 生成相对路径 + String relativePath = split + "/" + className + "/" + destFile.getName(); + + // 生成HTTP路径 + String httpPath = !dataApiPath.isEmpty() + ? dataApiPath + "/yolo/" + projectId + "/" + relativePath + : "/yolo/" + projectId + "/" + relativePath; + + // 生成绝对路径 + String absolutePath = destFile.getAbsolutePath(); + + // 构建返回Map:0=url路径, 1=文件路径, 2=标注路径(分类没有标注) + Map result = new java.util.HashMap<>(); + result.put(0, httpPath); + result.put(1, absolutePath); + result.put(2, ""); // 分类任务没有标注路径 + + imagePaths.add(result); + } catch (IOException e) { + log.error("复制文件失败: {}", imagePath, e); + } + } + + /** + * 处理检测任务数据集 + * 目录结构: + * data/ + * ├── images/ + * │ ├── train/ + * │ └── val/ + * └── labels/ + * ├── train/ + * └── val/ + */ + private void processDetectionDatasets(DatasDO datasDO, File yoloDir, String basePath, + String dataApiPath, List> imagePaths) { + // 解析数据集ID + String[] datasetIds = datasDO.getDatasets().split(","); + List datasetList = new ArrayList<>(); + for (String datasetIdStr : datasetIds) { + try { + Integer datasetId = Integer.parseInt(datasetIdStr.trim()); + DatasetDO dataset = datasetService.getDataset(datasetId); + if (dataset != null) { + datasetList.add(dataset); + } + } catch (NumberFormatException e) { + log.warn("无效的数据集ID: {}", datasetIdStr); + } + } + + // 收集所有图片 + List allImages = new ArrayList<>(); + for (DatasetDO dataset : datasetList) { + String datasetPath = dataset.getPath(); + if (datasetPath != null && !datasetPath.isEmpty()) { + File datasetDir = new File(datasetPath); + if (datasetDir.exists()) { + allImages.addAll(getAllImageFiles(datasetDir)); + } + } + } + + // 根据比例分配图片 + int totalImages = allImages.size(); + int trainCount = (int) (totalImages * datasDO.getTrainRatio() / 100.0); + int valCount = (int) (totalImages * datasDO.getValRatio() / 100.0); + int testCount = totalImages - trainCount - valCount; + + Collections.shuffle(allImages); + + // 创建目录结构 + File imagesTrainDir = new File(yoloDir, "images/train"); + File imagesValDir = new File(yoloDir, "images/val"); + File labelsTrainDir = new File(yoloDir, "labels/train"); + File labelsValDir = new File(yoloDir, "labels/val"); + + if (!imagesTrainDir.exists()) imagesTrainDir.mkdirs(); + if (!imagesValDir.exists()) imagesValDir.mkdirs(); + if (!labelsTrainDir.exists()) labelsTrainDir.mkdirs(); + if (!labelsValDir.exists()) labelsValDir.mkdirs(); + + // 处理训练集 + for (int i = 0; i < trainCount; i++) { + String imagePath = allImages.get(i); + processDetectionImage(imagePath, imagesTrainDir, labelsTrainDir, basePath, dataApiPath, + datasDO.getId(), "train", imagePaths); + } + + // 处理验证集 + for (int i = trainCount; i < trainCount + valCount; i++) { + String imagePath = allImages.get(i); + processDetectionImage(imagePath, imagesValDir, labelsValDir, basePath, dataApiPath, + datasDO.getId(), "val", imagePaths); + } + + // 测试集(合并到验证集) + for (int i = trainCount + valCount; i < totalImages; i++) { + String imagePath = allImages.get(i); + processDetectionImage(imagePath, imagesValDir, labelsValDir, basePath, dataApiPath, + datasDO.getId(), "val", imagePaths); + } + } + + /** + * 处理单张检测图片 + */ + private void processDetectionImage(String imagePath, File imagesDir, File labelsDir, String basePath, + String dataApiPath, Integer projectId, String split, + List> imagePaths) { + File sourceFile = new File(imagePath); + File destImageFile = new File(imagesDir, sourceFile.getName()); + + try { + copyFile(sourceFile, destImageFile); + + // 生成标注文件路径 + String labelFileName = sourceFile.getName().replaceAll("\\.[^.]+$", ".txt"); + File labelFile = new File(labelsDir, labelFileName); + + // 创建空的标注文件 + if (!labelFile.exists()) { + labelFile.createNewFile(); + } + + // 生成图片相对路径 + String imageRelativePath = "images/" + split + "/" + destImageFile.getName(); + + // 生成标注文件相对路径 + String labelRelativePath = "labels/" + split + "/" + labelFile.getName(); + + // 生成HTTP路径 + String httpPath = !dataApiPath.isEmpty() + ? dataApiPath + "/yolo/" + projectId + "/" + imageRelativePath + : "/yolo/" + projectId + "/" + imageRelativePath; + + // 生成绝对路径 + String absolutePath = destImageFile.getAbsolutePath(); + + // 生成标注文件绝对路径 + String labelAbsolutePath = labelFile.getAbsolutePath(); + + // 构建返回Map:0=url路径, 1=文件路径, 2=标注路径 + Map result = new java.util.HashMap<>(); + result.put(0, httpPath); + result.put(1, absolutePath); + result.put(2, labelAbsolutePath); + + imagePaths.add(result); + } catch (IOException e) { + log.error("复制文件失败: {}", imagePath, e); + } + } + @Override public Integer createDatas(DatasSaveReqVO createReqVO) { // 插入 DatasDO datas = BeanUtils.toBean(createReqVO, DatasDO.class); -// 查看数据集是否存在判断路径是否存在 - List imageNames = getImageNamesFromPath(createReqVO.getPath()); -//默认类型为正在标注 - datas.setStatus("1"); - datas.setCount((long) imageNames.size()); + + // 默认状态为还未标注 + datas.setStatus("0"); datas.setProgress(0.0); - for (String imageName : imageNames){ + + // 先插入数据 + datasMapper.insert(datas); + + // 处理数据集,复制图片并生成YOLO结构 + List> imagePaths = processDatasets(datas); + + // 保存图片路径到mark表 + for (Map imageInfo : imagePaths) { markService.save(new MarkDO() .setDataId(datas.getId()) - .setPath(imageName)); + .setPath(imageInfo.get(0)) // 保存URL路径 + .setImagePath(imageInfo.get(1)) // 保存图片绝对路径 + .setLabelPath(imageInfo.get(2)) // 保存标注文件路径 + .setStatus(0)); // 默认未标注 } - datasMapper.insert(datas); + + // 更新图片总数 + datas.setCount((long) imagePaths.size()); + datasMapper.updateById(datas); // 返回 return datas.getId(); @@ -167,8 +460,28 @@ public class DatasServiceImpl extends ServiceImpl impleme public void updateDatas(DatasSaveReqVO updateReqVO) { // 校验存在 validateDatasExists(updateReqVO.getId()); - // 更新 + + // 删除旧的标记记录 + markService.remove(new QueryWrapper().eq("data_id", updateReqVO.getId())); + + // 更新数据 DatasDO updateObj = BeanUtils.toBean(updateReqVO, DatasDO.class); + + // 处理数据集,复制图片并生成YOLO结构 + List> imagePaths = processDatasets(updateObj); + + // 保存图片路径到mark表 + for (Map imageInfo : imagePaths) { + markService.save(new MarkDO() + .setDataId(updateObj.getId()) + .setPath(imageInfo.get(0)) // 保存URL路径 + .setImagePath(imageInfo.get(1)) // 保存图片绝对路径 + .setLabelPath(imageInfo.get(2)) // 保存标注文件路径 + .setStatus(0)); + } + + // 更新图片总数 + updateObj.setCount((long) imagePaths.size()); datasMapper.updateById(updateObj); } @@ -176,10 +489,47 @@ public class DatasServiceImpl extends ServiceImpl impleme public void deleteDatas(Integer id) { // 校验存在 validateDatasExists(id); - // 删除 + + // 获取字典配置 + Map baseConf = dictDataService.getDictDataList("base_conf"); + DictDataDO basePathDO = baseConf.get("base_path"); + + if (basePathDO != null) { + String basePath = basePathDO.getValue(); + String yoloProjectDir = basePath + File.separator + "yolo" + File.separator + id; + File yoloDir = new File(yoloProjectDir); + + // 删除YOLO训练目录 + if (yoloDir.exists()) { + deleteDirectory(yoloDir); + } + } + + // 删除标记记录 + markService.remove(new QueryWrapper().eq("data_id", id)); + + // 删除数据 datasMapper.deleteById(id); } + /** + * 递归删除目录 + * @param directory 要删除的目录 + */ + private void deleteDirectory(File directory) { + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteDirectory(file); + } else { + file.delete(); + } + } + } + directory.delete(); + } + private void validateDatasExists(Integer id) { if (datasMapper.selectById(id) == null) { // throw exception(); @@ -195,75 +545,25 @@ public class DatasServiceImpl extends ServiceImpl impleme public PageResult getDatasPage(DatasPageReqVO pageReqVO) { return datasMapper.selectPage(pageReqVO); } + @Override public void refreshDatas(DatasDO datasDO) { - // 1. 获取路径中的所有图片文件 - List currentImageNames = getImageNamesFromPath(datasDO.getPath()); - - // 2. 获取数据库中已有的标记记录 - List existingMarks = markService.list(new QueryWrapper().eq("data_id", datasDO.getId())); - - // 3. 找出需要删除的图片(数据库中有但文件系统中没有) - List existingImageNames = existingMarks.stream() - .map(MarkDO::getPath) - .collect(Collectors.toList()); - - List imagesToDelete = existingImageNames.stream() - .filter(imageName -> !currentImageNames.contains(imageName)) - .collect(Collectors.toList()); - - // 删除不存在的标记记录 - if (!imagesToDelete.isEmpty()) { - markService.remove(new QueryWrapper() - .eq("data_id", datasDO.getId()) - .in("path", imagesToDelete)); - } - - // 4. 找出需要新增的图片(文件系统中有但数据库中没有) - List imagesToAdd = currentImageNames.stream() - .filter(imageName -> !existingImageNames.contains(imageName)) - .collect(Collectors.toList()); - - // 新增图片标记记录 - for (String imageName : imagesToAdd) { - markService.save(new MarkDO() - .setDataId(datasDO.getId()) - .setPath(imageName) - .setStatus(0)); // 默认未标注状态 - } - - // 5. 更新数据集统计信息 - List updatedMarks = markService.list(new QueryWrapper().eq("data_id", datasDO.getId())); - - // 计算进度 - long totalImages = updatedMarks.size(); - long completedImages = updatedMarks.stream() - .filter(mark -> mark.getStatus() != null && mark.getStatus() == 1) - .count(); - - double progress = totalImages > 0 ? (completedImages * 100.0 / totalImages) : 0.0; - - // 判断是否全部完成 - String status = (completedImages == totalImages && totalImages > 0) ? "2" : "1"; // 2表示完成,1表示进行中 - - // 更新数据集 - datasDO.setCount(totalImages); - datasDO.setProgress(progress); - datasDO.setStatus(status); - datasDO.setCreator( null); - datasDO.setUpdater(null); - datasMapper.updateById(datasDO); + // 不再使用path字段,此方法需要重新实现 + log.warn("refreshDatas方法已废弃,请使用updateDatas方法更新数据集"); } - @Resource - ImageService imageService; - @Resource - MarkInfoMapper markInfoMapper; @Override public void enhance(DatasEnhance datasEnhance) { + DictDataDO basePathDO = dictDataService.parseDictData("visual_annotation_conf", "base_path"); + + if (basePathDO == null) { + log.error("visual_annotation_conf.base_path 配置不存在"); + return; + } + + String basePath = basePathDO.getValue(); - DictDataDO basePath = dictDataService.parseDictData("visual_annotation_conf","base_path"); -// 遍历图片,根据选择的类型,新增图片,并且新增他自身的标记记录 + // 遍历图片,根据选择的类型,新增图片,并且新增他自身的标记记录 List markDOList = markService.list(new QueryWrapper() .eq("data_id", datasEnhance.getId())); @@ -277,7 +577,7 @@ public class DatasServiceImpl extends ServiceImpl impleme try { String path = imageService.enhanceImage( markDO.getPath(), - basePath.getValue(), + basePath, datasEnhance.getEnhancements()[index % datasEnhance.getEnhancements().length] ); @@ -285,20 +585,19 @@ public class DatasServiceImpl extends ServiceImpl impleme new QueryWrapper().eq("mark_id", markDO.getId()) ); - MarkDO newMarkDO = BeanUtils.toBean(markDO, MarkDO.class); - + MarkDO newMarkDO = BeanUtils.toBean(markDO, MarkDO.class); newMarkDO.setId(null); newMarkDO.setPath(path); markService.save(newMarkDO); for (MarkInfoDO markInfoDO : markInfoDOList) { - MarkInfoDO newMarkInfoDO = BeanUtils.toBean(markInfoDO, MarkInfoDO.class); + MarkInfoDO newMarkInfoDO = BeanUtils.toBean(markInfoDO, MarkInfoDO.class); newMarkInfoDO.setId(null); newMarkInfoDO.setMarkId(newMarkDO.getId()); markInfoMapper.insert(newMarkInfoDO); } } catch (Exception e) { - log.error("图像增强处理失败,图片路径: "+markDO.getPath(),e); + log.error("图像增强处理失败,图片路径: " + markDO.getPath(), e); } }); @@ -307,8 +606,6 @@ public class DatasServiceImpl extends ServiceImpl impleme // 等待所有异步任务完成 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - - } } \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/dataset/DatasetService.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/dataset/DatasetService.java new file mode 100644 index 0000000..67f3fde --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/dataset/DatasetService.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.annotation.service.dataset; + +import java.util.*; +import jakarta.validation.*; +import cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo.*; +import cn.iocoder.yudao.module.annotation.dal.dataobject.dataset.DatasetDO; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import org.springframework.web.multipart.MultipartFile; + +/** + * 识别数据集 Service 接口 + * + * @author 管理员 + */ +public interface DatasetService { + + /** + * 创建识别数据集 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Integer createDataset(@Valid DatasetSaveReqVO createReqVO); + + /** + * 更新识别数据集 + * + * @param updateReqVO 更新信息 + */ + void updateDataset(@Valid DatasetSaveReqVO updateReqVO); + + /** + * 删除识别数据集 + * + * @param id 编号 + */ + void deleteDataset(Integer id); + + /** + * 获得识别数据集 + * + * @param id 编号 + * @return 识别数据集 + */ + DatasetDO getDataset(Integer id); + + /** + * 获得识别数据集分页 + * + * @param pageReqVO 分页查询 + * @return 识别数据集分页 + */ + PageResult getDatasetPage(DatasetPageReqVO pageReqVO); + + /** + * 向数据集添加图片 + * + * @param id 数据集ID + * @param files 文件数组(支持zip压缩包或多张图片) + * @return 保存的图片URL列表 + */ + List addImages(Integer id, MultipartFile[] files); + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/dataset/DatasetServiceImpl.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/dataset/DatasetServiceImpl.java new file mode 100644 index 0000000..5ed452a --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/dataset/DatasetServiceImpl.java @@ -0,0 +1,415 @@ +package cn.iocoder.yudao.module.annotation.service.dataset; + +import org.springframework.stereotype.Service; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo.*; +import cn.iocoder.yudao.module.annotation.dal.dataobject.dataset.DatasetDO; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; + +import cn.iocoder.yudao.module.annotation.dal.mysql.dataset.DatasetMapper; +import cn.iocoder.yudao.module.system.service.dict.DictDataService; +import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.annotation.enums.ErrorCodeConstants.*; +import lombok.extern.slf4j.Slf4j; + +/** + * 识别数据集 Service 实现类 + * + * @author 管理员 + */ +@Service +@Validated +@Slf4j +public class DatasetServiceImpl implements DatasetService { + + @Resource + private DatasetMapper datasetMapper; + + @Resource + private DictDataService dictDataService; + + @Override + public Integer createDataset(DatasetSaveReqVO createReqVO) { + // 插入 + DatasetDO dataset = BeanUtils.toBean(createReqVO, DatasetDO.class); + + // 如果未指定路径,自动生成路径 + if (dataset.getPath() == null || dataset.getPath().trim().isEmpty()) { + Map dictDataMap = dictDataService.getDictDataList("visual_annotation_conf"); + String basePath = dictDataMap.get("base_path").getValue(); + String baseUrl = dictDataMap.get("base_url").getValue(); + + // 插入数据库获取ID + datasetMapper.insert(dataset); + Integer id = dataset.getId(); + + // 生成路径:base_path/dataset/id/ + String datasetPath = basePath + "dataset/" + id + "/"; + String datasetUrl = baseUrl + "dataset/" + id + "/"; + + // 创建目录 + File dir = new File(datasetPath); + if (!dir.exists()) { + dir.mkdirs(); + log.info("创建数据集目录: {}", datasetPath); + } + + // 更新路径 + dataset.setPath(datasetUrl); + datasetMapper.updateById(dataset); + + log.info("创建数据集,ID={}, 自动生成路径={}", id, datasetUrl); + } else { + // 使用指定路径 + datasetMapper.insert(dataset); + } + + // 如果有上传文件,处理文件 + if (createReqVO.getFiles() != null && createReqVO.getFiles().length > 0) { + processFiles(dataset.getId(), createReqVO.getFiles()); + } + + // 返回 + return dataset.getId(); + } + + @Override + public void updateDataset(DatasetSaveReqVO updateReqVO) { + // 校验存在 + validateDatasetExists(updateReqVO.getId()); + // 更新 + DatasetDO updateObj = BeanUtils.toBean(updateReqVO, DatasetDO.class); + + // 如果更新了路径,且之前路径为空或使用默认路径,需要处理 + DatasetDO existingDataset = datasetMapper.selectById(updateReqVO.getId()); + if (existingDataset != null && updateObj.getPath() != null && + !updateObj.getPath().equals(existingDataset.getPath())) { + // 如果指定了新的路径,直接使用 + datasetMapper.updateById(updateObj); + } else if (existingDataset != null && + (existingDataset.getPath() == null || existingDataset.getPath().trim().isEmpty())) { + // 如果之前路径为空,生成默认路径 + Map dictDataMap = dictDataService.getDictDataList("visual_annotation_conf"); + String basePath = dictDataMap.get("base_path").getValue(); + String baseUrl = dictDataMap.get("base_url").getValue(); + + String datasetPath = basePath + "dataset/" + updateReqVO.getId() + "/"; + String datasetUrl = baseUrl + "dataset/" + updateReqVO.getId() + "/"; + + // 创建目录 + File dir = new File(datasetPath); + if (!dir.exists()) { + dir.mkdirs(); + log.info("创建数据集目录: {}", datasetPath); + } + + updateObj.setPath(datasetUrl); + datasetMapper.updateById(updateObj); + } else { + datasetMapper.updateById(updateObj); + } + + // 如果有上传文件,处理文件 + if (updateReqVO.getFiles() != null && updateReqVO.getFiles().length > 0) { + processFiles(updateReqVO.getId(), updateReqVO.getFiles()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDataset(Integer id) { + // 校验存在 + validateDatasetExists(id); + + // 获取数据集信息 + DatasetDO dataset = datasetMapper.selectById(id); + + // 删除数据库记录 + datasetMapper.deleteById(id); + + // 删除对应的文件夹 + if (dataset != null && dataset.getPath() != null && !dataset.getPath().trim().isEmpty()) { + Map dictDataMap = dictDataService.getDictDataList("visual_annotation_conf"); + String basePath = dictDataMap.get("base_path").getValue(); + String baseUrl = dictDataMap.get("base_url").getValue(); + + // 从URL转换为文件路径 + String datasetPath = dataset.getPath().replace(baseUrl, basePath); + File datasetDir = new File(datasetPath); + + if (datasetDir.exists() && datasetDir.isDirectory()) { + boolean deleted = deleteDirectory(datasetDir); + if (deleted) { + log.info("删除数据集文件夹成功: {}", datasetPath); + } else { + log.warn("删除数据集文件夹失败: {}", datasetPath); + } + } + } + } + + @Override + public List addImages(Integer id, MultipartFile[] files) { + // 校验数据集存在 + validateDatasetExists(id); + + // 获取数据集信息 + DatasetDO dataset = datasetMapper.selectById(id); + + if (dataset == null || dataset.getPath() == null || dataset.getPath().trim().isEmpty()) { + throw exception(DATASET_PATH_NOT_SET); + } + + return processFiles(id, files); + } + + /** + * 处理上传的文件(支持zip和多张图片) + * + * @param datasetId 数据集ID + * @param files 文件数组 + * @return 保存的图片URL列表 + */ + private List processFiles(Integer datasetId, MultipartFile[] files) { + if (files == null || files.length == 0) { + log.warn("没有上传任何文件"); + return new ArrayList<>(); + } + + Map dictDataMap = dictDataService.getDictDataList("visual_annotation_conf"); + String basePath = dictDataMap.get("base_path").getValue(); + String baseUrl = dictDataMap.get("base_url").getValue(); + + // 目标文件夹:base_path/dataset/id/ + File targetDir = new File(basePath + "dataset/" + datasetId + "/"); + if (!targetDir.exists()) { + targetDir.mkdirs(); + log.info("创建目标文件夹: {}", targetDir.getAbsolutePath()); + } + + List savedImagePaths = new ArrayList<>(); + + for (MultipartFile file : files) { + try { + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.trim().isEmpty()) { + log.warn("文件名为空,跳过"); + continue; + } + + // 检查是否是zip文件 + if (originalFilename.toLowerCase().endsWith(".zip")) { + // 解压zip文件 + List extractedFiles = extractZipFile(file, targetDir, baseUrl, datasetId); + savedImagePaths.addAll(extractedFiles); + log.info("解压zip文件: {}, 提取 {} 个文件", originalFilename, extractedFiles.size()); + } else if (isImageFile(originalFilename)) { + // 保存图片文件 + String savedPath = saveImageFile(file, targetDir, baseUrl, datasetId); + if (savedPath != null && !savedPath.isEmpty()) { + savedImagePaths.add(savedPath); + } + } else { + log.warn("非zip或图片文件,跳过: {}", originalFilename); + } + } catch (IOException e) { + log.error("处理文件失败: {}", file.getOriginalFilename(), e); + } + } + + log.info("处理文件完成,数据集ID={}, 共保存 {} 个文件", datasetId, savedImagePaths.size()); + return savedImagePaths; + } + + /** + * 解压zip文件 + * + * @param zipFile zip文件 + * @param targetDir 目标目录 + * @param baseUrl 基础URL + * @param datasetId 数据集ID + * @return 解压后的图片URL列表 + */ + private List extractZipFile(MultipartFile zipFile, File targetDir, String baseUrl, Integer datasetId) throws IOException { + List extractedFiles = new ArrayList<>(); + + try (ZipInputStream zipIn = new ZipInputStream(zipFile.getInputStream())) { + ZipEntry entry; + byte[] buffer = new byte[1024]; + + while ((entry = zipIn.getNextEntry()) != null) { + String entryName = entry.getName(); + + // 跳过目录 + if (entry.isDirectory()) { + continue; + } + + // 检查是否是图片文件 + if (!isImageFile(entryName)) { + log.debug("跳过非图片文件: {}", entryName); + continue; + } + + // 提取文件名(去掉路径) + String fileName = getFileNameFromPath(entryName); + File outputFile = new File(targetDir, fileName); + + // 保存文件 + try (FileOutputStream fos = new FileOutputStream(outputFile)) { + int len; + while ((len = zipIn.read(buffer)) > 0) { + fos.write(buffer, 0, len); + } + } + + String fileUrl = baseUrl + "dataset/" + datasetId + "/" + fileName; + extractedFiles.add(fileUrl); + log.info("解压文件: {} -> {}", entryName, fileUrl); + } + + zipIn.closeEntry(); + } + + return extractedFiles; + } + + /** + * 保存图片文件 + * + * @param file 图片文件 + * @param targetDir 目标目录 + * @param baseUrl 基础URL + * @param datasetId 数据集ID + * @return 图片URL + */ + private String saveImageFile(MultipartFile file, File targetDir, String baseUrl, Integer datasetId) throws IOException { + String originalFilename = file.getOriginalFilename(); + String fileExtension = getFileExtension(originalFilename); + + // 生成唯一文件名(避免重复) + String uniqueFileName = generateUniqueFileName(targetDir, originalFilename, fileExtension); + File targetFile = new File(targetDir, uniqueFileName); + + // 保存文件 + file.transferTo(targetFile); + log.info("保存图片: {} -> {}", originalFilename, targetFile.getAbsolutePath()); + + // 返回URL路径 + return baseUrl + "dataset/" + datasetId + "/" + uniqueFileName; + } + + /** + * 递归删除目录 + * + * @param directory 目录 + * @return 是否删除成功 + */ + private boolean deleteDirectory(File directory) { + if (directory == null || !directory.exists()) { + return false; + } + + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteDirectory(file); + } else { + file.delete(); + } + } + } + + return directory.delete(); + } + + /** + * 判断文件是否为图片 + */ + private boolean isImageFile(String fileName) { + String lowerName = fileName.toLowerCase(); + return lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") || + lowerName.endsWith(".png") || lowerName.endsWith(".bmp") || + lowerName.endsWith(".gif"); + } + + /** + * 获取文件扩展名 + */ + private String getFileExtension(String fileName) { + int lastDotIndex = fileName.lastIndexOf("."); + if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) { + return fileName.substring(lastDotIndex + 1).toLowerCase(); + } + return ""; + } + + /** + * 从路径中提取文件名 + */ + private String getFileNameFromPath(String path) { + int lastSlashIndex = path.lastIndexOf("/"); + if (lastSlashIndex >= 0) { + return path.substring(lastSlashIndex + 1); + } + return path; + } + + /** + * 生成唯一文件名 + */ + private String generateUniqueFileName(File targetDir, String originalFileName, String extension) { + String baseName = originalFileName; + int dotIndex = originalFileName.lastIndexOf("."); + if (dotIndex > 0) { + baseName = originalFileName.substring(0, dotIndex); + } + + String fileName = originalFileName; + int counter = 1; + + // 如果文件已存在,添加序号 + File file = new File(targetDir, fileName); + while (file.exists()) { + fileName = baseName + "_" + counter + "." + extension; + file = new File(targetDir, fileName); + counter++; + } + + return fileName; + } + + private void validateDatasetExists(Integer id) { + if (datasetMapper.selectById(id) == null) { + throw exception(DATASET_NOT_EXISTS); + } + } + + @Override + public DatasetDO getDataset(Integer id) { + return datasetMapper.selectById(id); + } + + @Override + public PageResult getDatasetPage(DatasetPageReqVO pageReqVO) { + return datasetMapper.selectPage(pageReqVO); + } + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/mark/MarkService.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/mark/MarkService.java index bcec137..84f30f0 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/mark/MarkService.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/mark/MarkService.java @@ -52,4 +52,33 @@ public interface MarkService extends IService { */ PageResult getMarkPage(MarkPageReqVO pageReqVO); + /** + * 生成标注文件(根据项目类型:检测或分类) + * + * @param markId 标注ID + */ + void generateAnnotationFile(Integer markId); + + /** + * 更新标注文件内容 + * + * @param markId 标注ID + */ + void updateAnnotationFile(Integer markId); + + /** + * 删除标注文件 + * + * @param markId 标注ID + */ + void deleteAnnotationFile(Integer markId); + + /** + * 读取标注文件内容 + * + * @param markId 标注ID + * @return 标注文件内容 + */ + String readAnnotationFile(Integer markId); + } \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/mark/MarkServiceImpl.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/mark/MarkServiceImpl.java index 3005fc5..7572c7a 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/mark/MarkServiceImpl.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/mark/MarkServiceImpl.java @@ -1,15 +1,41 @@ package cn.iocoder.yudao.module.annotation.service.mark; +import cn.hutool.core.io.FileUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.annotation.controller.admin.mark.vo.MarkPageReqVO; import cn.iocoder.yudao.module.annotation.controller.admin.mark.vo.MarkSaveReqVO; +import cn.iocoder.yudao.module.annotation.dal.dataobject.datas.DatasDO; import cn.iocoder.yudao.module.annotation.dal.dataobject.mark.MarkDO; +import cn.iocoder.yudao.module.annotation.dal.dataobject.mark.MarkInfoDO; +import cn.iocoder.yudao.module.annotation.dal.dataobject.types.TypesDO; import cn.iocoder.yudao.module.annotation.dal.mysql.mark.MarkMapper; +import cn.iocoder.yudao.module.annotation.service.MarkInfo.AnnotationData; +import cn.iocoder.yudao.module.annotation.service.MarkInfo.MarkInfoService; +import cn.iocoder.yudao.module.annotation.service.datas.DatasService; +import cn.iocoder.yudao.module.annotation.service.types.TypesService; +import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO; +import cn.iocoder.yudao.module.system.service.dict.DictDataService; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; //import static cn.iocoder.yudao.module.annotation.enums.ErrorCodeConstants.*; /** @@ -17,6 +43,7 @@ import org.springframework.validation.annotation.Validated; * * @author 管理员 */ +@Slf4j @Service @Validated public class MarkServiceImpl extends ServiceImpl implements MarkService { @@ -24,6 +51,19 @@ public class MarkServiceImpl extends ServiceImpl implements @Resource private MarkMapper markMapper; + @Resource + private MarkInfoService markInfoService; + + @Lazy + @Resource + private DatasService datasService; + + @Resource + private TypesService typesService; + + @Resource + private DictDataService dictDataService; + @Override public Integer createMark(MarkSaveReqVO createReqVO) { // 插入 @@ -47,7 +87,8 @@ public class MarkServiceImpl extends ServiceImpl implements public void deleteMark(Integer id) { // 校验存在 validateMarkExists(id); - // 删除 + + // 删除数据库记录 markMapper.deleteById(id); } @@ -67,4 +108,367 @@ public class MarkServiceImpl extends ServiceImpl implements return markMapper.selectPage(pageReqVO); } + @Override + public void generateAnnotationFile(Integer markId) { + MarkDO mark = markMapper.selectById(markId); + if (mark == null) { + log.error("标注不存在: {}", markId); + return; + } + + DatasDO datas = datasService.getDatas(mark.getDataId()); + if (datas == null) { + log.error("项目不存在: {}", mark.getDataId()); + return; + } + + String projectType = datas.getType(); + String projectPath = datas.getPath(); + + try { + if ("1".equals(projectType)) { + // 检测任务:生成YOLO格式的标注文件 + generateDetectionAnnotationFile(mark, projectPath); + } else if ("2".equals(projectType)) { + // 分类任务:移动图片到对应的类别目录 + generateClassificationAnnotation(mark, projectPath, datas); + } + } catch (Exception e) { + log.error("生成标注文件失败: markId={}, projectType={}", markId, projectType, e); + } + } + + @Override + public void updateAnnotationFile(Integer markId) { + MarkDO mark = markMapper.selectById(markId); + if (mark == null) { + log.error("标注不存在: {}", markId); + return; + } + + DatasDO datas = datasService.getDatas(mark.getDataId()); + if (datas == null) { + log.error("项目不存在: {}", mark.getDataId()); + return; + } + + String projectType = datas.getType(); + String projectPath = datas.getPath(); + + try { + if ("1".equals(projectType)) { + // 检测任务:更新YOLO格式的标注文件 + updateDetectionAnnotationFile(mark, projectPath); + } else if ("2".equals(projectType)) { + // 分类任务:更新图片的类别位置 + updateClassificationAnnotation(mark, projectPath, datas); + } + } catch (Exception e) { + log.error("更新标注文件失败: markId={}, projectType={}", markId, projectType, e); + } + } + + @Override + public void deleteAnnotationFile(Integer markId) { + MarkDO mark = markMapper.selectById(markId); + if (mark == null) { + log.error("标注不存在: {}", markId); + return; + } + + DatasDO datas = datasService.getDatas(mark.getDataId()); + if (datas == null) { + log.error("项目不存在: {}", mark.getDataId()); + return; + } + + String projectType = datas.getType(); + String projectPath = datas.getPath(); + + try { + if ("1".equals(projectType)) { + // 检测任务:删除标注文件 + deleteDetectionAnnotationFile(mark, projectPath); + } else if ("2".equals(projectType)) { + // 分类任务:删除图片文件 + deleteClassificationAnnotation(mark, projectPath); + } + } catch (Exception e) { + log.error("删除标注文件失败: markId={}, projectType={}", markId, projectType, e); + } + } + + @Override + public String readAnnotationFile(Integer markId) { + MarkDO mark = markMapper.selectById(markId); + if (mark == null) { + log.error("标注不存在: {}", markId); + return null; + } + + DatasDO datas = datasService.getDatas(mark.getDataId()); + if (datas == null) { + log.error("项目不存在: {}", mark.getDataId()); + return null; + } + + String projectType = datas.getType(); + String projectPath = datas.getPath(); + + try { + if ("1".equals(projectType)) { + // 检测任务:读取标注文件内容 + return readDetectionAnnotationFile(mark, projectPath); + } else if ("2".equals(projectType)) { + // 分类任务:返回类别信息 + return readClassificationAnnotation(mark, datas); + } + } catch (Exception e) { + log.error("读取标注文件失败: markId={}, projectType={}", markId, projectType, e); + } + + return null; + } + + /** + * 生成检测任务的标注文件(YOLO格式) + */ + private void generateDetectionAnnotationFile(MarkDO mark, String projectPath) throws Exception { + // 获取基础路径 + DictDataDO basePathDO = dictDataService.parseDictData("visual_annotation_conf", "base_path"); + String basePath = basePathDO != null ? basePathDO.getValue() : ""; + + // 获取图片路径 + String imagePath = basePath + mark.getPath(); + File imageFile = new File(imagePath); + if (!imageFile.exists()) { + log.error("图片文件不存在: {}", imagePath); + return; + } + + // 获取图片尺寸 + BufferedImage image = ImageIO.read(imageFile); + int imageWidth = image.getWidth(); + int imageHeight = image.getHeight(); + + // 获取项目类型映射 + List typesList = typesService.list(new QueryWrapper().eq("data_id", mark.getDataId())); + typesList.sort(Comparator.comparing(TypesDO::getId)); + Map classIdMap = new HashMap<>(); + for (int i = 0; i < typesList.size(); i++) { + classIdMap.put(typesList.get(i).getId(), i); + } + + // 获取标注信息 + List markInfoList = markInfoService.list(new QueryWrapper() + .eq("data_id", mark.getDataId()).eq("mark_id", mark.getId())); + + // 确定标注文件目录(labels目录下) + String labelFileName = imageFile.getName().replaceAll("\\.[^.]+$", ".txt"); + File labelFile = new File(projectPath, "labels/" + labelFileName); + File labelDir = labelFile.getParentFile(); + if (!labelDir.exists()) { + labelDir.mkdirs(); + } + + // 生成YOLO格式的标注文件 + try (PrintWriter writer = new PrintWriter(new FileWriter(labelFile))) { + for (MarkInfoDO markInfo : markInfoList) { + Integer classIndex = classIdMap.get(markInfo.getClassId()); + if (classIndex != null) { + AnnotationData annotationData = parseAnnotationData(markInfo); + if (annotationData != null) { + String yoloLine = convertToYOLOFormat(annotationData, classIndex, imageWidth, imageHeight); + if (yoloLine != null) { + writer.println(yoloLine); + } + } + } + } + } + + // 更新数据库中的标注文件路径 + mark.setPath("labels/" + labelFileName); + markMapper.updateById(mark); + + log.info("生成检测标注文件成功: {}", labelFile.getAbsolutePath()); + } + + /** + * 生成分类任务的标注(移动图片到对应类别目录) + */ + private void generateClassificationAnnotation(MarkDO mark, String projectPath, DatasDO datas) throws Exception { + // 获取基础路径 + DictDataDO basePathDO = dictDataService.parseDictData("visual_annotation_conf", "base_path"); + String basePath = basePathDO != null ? basePathDO.getValue() : ""; + + // 获取标注信息(分类通常只有一个主要类别) + List markInfoList = markInfoService.list(new QueryWrapper() + .eq("data_id", mark.getDataId()).eq("mark_id", mark.getId())); + + if (markInfoList.isEmpty()) { + log.error("没有找到标注信息: markId={}", mark.getId()); + return; + } + + // 获取类别名称 + TypesDO type = typesService.getById(markInfoList.get(0).getClassId()); + if (type == null) { + log.error("类别不存在: classId={}", markInfoList.get(0).getClassId()); + return; + } + + String className = type.getName(); + + // 源图片路径 + String sourceImagePath = basePath + mark.getPath(); + File sourceImage = new File(sourceImagePath); + if (!sourceImage.exists()) { + log.error("源图片文件不存在: {}", sourceImagePath); + return; + } + + // 确定目标目录(images/train/classify/类名/) + String targetDirPath = projectPath + "/images/train/classify/" + className; + File targetDir = new File(targetDirPath); + if (!targetDir.exists()) { + targetDir.mkdirs(); + } + + // 移动图片到目标目录 + String imageFileName = sourceImage.getName(); + File targetImage = new File(targetDir, imageFileName); + Files.move(sourceImage.toPath(), targetImage.toPath(), StandardCopyOption.REPLACE_EXISTING); + + // 更新数据库中的图片路径 + String relativePath = targetDirPath.substring(projectPath.length() + 1) + "/" + imageFileName; + mark.setPath(relativePath); + markMapper.updateById(mark); + + log.info("生成分类标注成功: {}", targetImage.getAbsolutePath()); + } + + /** + * 更新检测任务的标注文件 + */ + private void updateDetectionAnnotationFile(MarkDO mark, String projectPath) throws Exception { + // 重新生成标注文件 + generateDetectionAnnotationFile(mark, projectPath); + } + + /** + * 更新分类任务的标注 + */ + private void updateClassificationAnnotation(MarkDO mark, String projectPath, DatasDO datas) throws Exception { + // 重新移动图片到新的类别目录 + generateClassificationAnnotation(mark, projectPath, datas); + } + + /** + * 删除检测任务的标注文件 + */ + private void deleteDetectionAnnotationFile(MarkDO mark, String projectPath) throws IOException { + File labelFile = new File(projectPath, mark.getPath()); + if (labelFile.exists()) { + FileUtil.del(labelFile); + log.info("删除检测标注文件成功: {}", labelFile.getAbsolutePath()); + } + } + + /** + * 删除分类任务的标注(图片文件) + */ + private void deleteClassificationAnnotation(MarkDO mark, String projectPath) throws IOException { + // 获取基础路径 + DictDataDO basePathDO = dictDataService.parseDictData("visual_annotation_conf", "base_path"); + String basePath = basePathDO != null ? basePathDO.getValue() : ""; + + File imageFile = new File(basePath, mark.getPath()); + if (imageFile.exists()) { + FileUtil.del(imageFile); + log.info("删除分类图片成功: {}", imageFile.getAbsolutePath()); + } + } + + /** + * 读取检测任务的标注文件内容 + */ + private String readDetectionAnnotationFile(MarkDO mark, String projectPath) throws IOException { + File labelFile = new File(projectPath, mark.getPath()); + if (!labelFile.exists()) { + return ""; + } + return FileUtil.readUtf8String(labelFile); + } + + /** + * 读取分类任务的标注信息 + */ + private String readClassificationAnnotation(MarkDO mark, DatasDO datas) { + // 获取标注信息 + List markInfoList = markInfoService.list(new QueryWrapper() + .eq("data_id", mark.getDataId()).eq("mark_id", mark.getId())); + + if (!markInfoList.isEmpty()) { + TypesDO type = typesService.getById(markInfoList.get(0).getClassId()); + if (type != null) { + return type.getName(); + } + } + + return ""; + } + + /** + * 解析标注数据 + */ + private AnnotationData parseAnnotationData(MarkInfoDO markInfo) { + if (markInfo.getAnnotationData() != null) { + return markInfo.getAnnotationData(); + } + if (markInfo.getAnnotationDataString() != null && !markInfo.getAnnotationDataString().isEmpty()) { + return AnnotationData.fromString(markInfo.getAnnotationDataString()); + } + return null; + } + + /** + * 转换为YOLO格式 + */ + private String convertToYOLOFormat(AnnotationData annotationData, Integer classIndex, + int imageWidth, int imageHeight) { + try { + AnnotationData.Target.Selector.Geometry geometry = annotationData.getTarget().getSelector().getGeometry(); + + // 获取边界框信息 + double x = geometry.getX() != null ? geometry.getX() : 0; + double y = geometry.getY() != null ? geometry.getY() : 0; + double w = geometry.getW() != null ? geometry.getW() : 0; + double h = geometry.getH() != null ? geometry.getH() : 0; + + // 如果使用基本字段 + if (x == 0 && y == 0 && w == 0 && h == 0) { + return null; + } + + // 转换为YOLO格式(相对坐标和宽高) + double centerX = (x + w / 2) / imageWidth; + double centerY = (y + h / 2) / imageHeight; + double width = w / imageWidth; + double height = h / imageHeight; + + // 确保坐标在0-1范围内 + centerX = Math.max(0, Math.min(1, centerX)); + centerY = Math.max(0, Math.min(1, centerY)); + width = Math.max(0, Math.min(1, width)); + height = Math.max(0, Math.min(1, height)); + + return String.format("%d %.6f %.6f %.6f %.6f", classIndex, centerX, centerY, width, height); + + } catch (Exception e) { + log.error("转换YOLO格式失败", e); + return null; + } + } + } \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/model/ModelService.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/model/ModelService.java new file mode 100644 index 0000000..61477a6 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/model/ModelService.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.annotation.service.model; + +import java.util.*; +import jakarta.validation.*; +import cn.iocoder.yudao.module.annotation.controller.admin.model.vo.*; +import cn.iocoder.yudao.module.annotation.dal.dataobject.model.ModelDO; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; + +/** + * 模型管理 Service 接口 + * + * @author 管理员 + */ +public interface ModelService { + + /** + * 创建模型管理 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Integer createModel(@Valid ModelSaveReqVO createReqVO); + + /** + * 更新模型管理 + * + * @param updateReqVO 更新信息 + */ + void updateModel(@Valid ModelSaveReqVO updateReqVO); + + /** + * 删除模型管理 + * + * @param id 编号 + */ + void deleteModel(Integer id); + + /** + * 获得模型管理 + * + * @param id 编号 + * @return 模型管理 + */ + ModelDO getModel(Integer id); + + /** + * 获得模型管理分页 + * + * @param pageReqVO 分页查询 + * @return 模型管理分页 + */ + PageResult getModelPage(ModelPageReqVO pageReqVO); + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/model/ModelServiceImpl.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/model/ModelServiceImpl.java new file mode 100644 index 0000000..29ad08f --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/model/ModelServiceImpl.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.annotation.service.model; + +import org.springframework.stereotype.Service; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import cn.iocoder.yudao.module.annotation.controller.admin.model.vo.*; +import cn.iocoder.yudao.module.annotation.dal.dataobject.model.ModelDO; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; + +import cn.iocoder.yudao.module.annotation.dal.mysql.model.ModelMapper; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.annotation.enums.ErrorCodeConstants.*; + +/** + * 模型管理 Service 实现类 + * + * @author 管理员 + */ +@Service +@Validated +public class ModelServiceImpl implements ModelService { + + @Resource + private ModelMapper modelMapper; + + @Override + public Integer createModel(ModelSaveReqVO createReqVO) { + // 插入 + ModelDO model = BeanUtils.toBean(createReqVO, ModelDO.class); + modelMapper.insert(model); + // 返回 + return model.getId(); + } + + @Override + public void updateModel(ModelSaveReqVO updateReqVO) { + // 校验存在 + validateModelExists(updateReqVO.getId()); + // 更新 + ModelDO updateObj = BeanUtils.toBean(updateReqVO, ModelDO.class); + modelMapper.updateById(updateObj); + } + + @Override + public void deleteModel(Integer id) { + // 校验存在 + validateModelExists(id); + // 删除 + modelMapper.deleteById(id); + } + + private void validateModelExists(Integer id) { + if (modelMapper.selectById(id) == null) { + throw exception(MODEL_NOT_EXISTS); + } + } + + @Override + public ModelDO getModel(Integer id) { + return modelMapper.selectById(id); + } + + @Override + public PageResult getModelPage(ModelPageReqVO pageReqVO) { + return modelMapper.selectPage(pageReqVO); + } + +} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/python/PythonVirtualEnvServiceImpl.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/python/PythonVirtualEnvServiceImpl.java index 465f49c..71646a1 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/python/PythonVirtualEnvServiceImpl.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/python/PythonVirtualEnvServiceImpl.java @@ -1,12 +1,14 @@ package cn.iocoder.yudao.module.annotation.service.python; import cn.iocoder.yudao.module.annotation.service.python.vo.PythonVirtualEnvInfo; +import cn.iocoder.yudao.module.annotation.service.yolo.YoloTrainConfig; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import java.io.BufferedReader; -import java.io.File; -import java.io.InputStreamReader; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; /** * Python虚拟环境服务实现类 @@ -17,6 +19,147 @@ import java.io.InputStreamReader; @Service public class PythonVirtualEnvServiceImpl implements PythonVirtualEnvService { + + /** + * 执行YOLO训练 最终版(2026/4/10) + * @param config 训练配置 + * @return 训练结果径 + */ + public static String executeTraining(YoloTrainConfig config) { + // ========== 配置参数 ========== + + // YOLO训练参数 + String modelPath = config.getModelPath(); + String datasetPath = config.getDatasetPath(); + int epochs = config.getEpochs(); + int batchSize = config.getBatchSize(); + int imageSize = config.getImageSize(); + String device = config.getDevice(); + String outputPath = config.getOutputPath(); + String logFilePath = config.getLogFilePath(); + int projectId = config.getProjectId(); + String venvPath = config.getVenvPath(); + String resultPath = null; + + // 检测操作系统 + boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows"); + + // 虚拟环境激活脚本路径 + String activateScript; + String shellCommand; + if (isWindows) { + activateScript = venvPath + "/Scripts/activate.bat"; + shellCommand = "cmd"; + } else { + activateScript = venvPath + "/bin/activate"; + shellCommand = "bash"; + } + + String batchFilePath = outputPath + "/train" + projectId + (isWindows ? ".bat" : ".sh"); + + // 创建批处理文件内容 + StringBuilder batchContent = new StringBuilder(); + if (isWindows) { + // Windows 批处理文件 + batchContent.append("@echo off\r\n"); + batchContent.append("call \"").append(activateScript).append("\"\r\n"); + batchContent.append("yolo train model=\"").append(modelPath).append("\" data=\"").append(datasetPath) + .append("\" epochs=").append(epochs).append(" batch=").append(batchSize) + .append(" imgsz=").append(imageSize).append(" device=").append(device) + .append(" project=\"").append(outputPath).append("\" name=train").append(projectId).append("\"\r\n"); + } else { + // Linux Shell 脚本 + batchContent.append("#!/bin/bash\n"); + batchContent.append("source \"").append(activateScript).append("\"\n"); + batchContent.append("yolo train model=\"").append(modelPath).append("\" data=\"").append(datasetPath) + .append("\" epochs=").append(epochs).append(" batch=").append(batchSize) + .append(" imgsz=").append(imageSize).append(" device=").append(device) + .append(" project=\"").append(outputPath).append("\" name=train").append(projectId).append("\"\n"); + } + + System.out.println("========================================"); + System.out.println("YOLO训练开始"); + System.out.println("========================================"); + System.out.println("模型: " + modelPath); + System.out.println("数据集: " + datasetPath); + System.out.println("训练轮数: " + epochs); + System.out.println("批次大小: " + batchSize); + System.out.println("图像大小: " + imageSize); + System.out.println("设备: " + device); + System.out.println("输出路径: " + outputPath); + System.out.println("日志文件: " + logFilePath); + System.out.println("项目编号: " + projectId); + System.out.println("虚拟环境路径: " + venvPath); + System.out.println("操作系统: " + (isWindows ? "Windows" : "Linux")); + System.out.println("========================================"); + + try { + // 创建输出目录 + Files.createDirectories(Paths.get(outputPath)); + + // 写入脚本文件 + Files.write(Paths.get(batchFilePath), batchContent.toString().getBytes(StandardCharsets.UTF_8)); + System.out.println("脚本文件已创建: " + batchFilePath); + + // 如果是Linux,需要给脚本添加执行权限 + if (!isWindows) { + new File(batchFilePath).setExecutable(true); + } + + // 执行脚本文件 + ProcessBuilder processBuilder; + if (isWindows) { + processBuilder = new ProcessBuilder("cmd", "/c", batchFilePath); + } else { + processBuilder = new ProcessBuilder(shellCommand, batchFilePath); + } + processBuilder.redirectErrorStream(true); + + Process process = processBuilder.start(); + + // 读取输出 + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), "UTF-8")); + BufferedWriter logWriter = new BufferedWriter(new FileWriter(logFilePath, StandardCharsets.UTF_8)); + + String line; + while ((line = reader.readLine()) != null) { + // 输出到控制台 + System.out.println(line); + + // 解析训练结果路径 + if (line.contains("Results saved to ")) { + resultPath = line.substring(line.indexOf("Results saved to ") + 16).trim(); + } + + // 写入日志文件 + logWriter.write(line); + logWriter.newLine(); + } + + logWriter.close(); + reader.close(); + + // 等待进程结束 + int exitCode = process.waitFor(); + + System.out.println("========================================"); + if (exitCode == 0) { + System.out.println("训练完成!"); + } else { + System.out.println("训练异常结束,退出码: " + exitCode); + } + System.out.println("日志已保存到: " + logFilePath); + System.out.println("训练结果路径: " + (resultPath != null ? resultPath : outputPath)); + System.out.println("========================================"); + + } catch (Exception e) { + System.err.println("训练过程中发生错误: " + e.getMessage()); + e.printStackTrace(); + } + + return resultPath; + } + @Override public PythonVirtualEnvInfo activateVirtualEnv(String pythonPath, String envName) { PythonVirtualEnvInfo info = new PythonVirtualEnvInfo(); diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/Spring环境使用说明.md b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/Spring环境使用说明.md new file mode 100644 index 0000000..54dd327 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/Spring环境使用说明.md @@ -0,0 +1,267 @@ +# 如何在Spring环境中使用YOLO训练 + +## 问题说明 + +如果你直接 `new YoloOperationServiceImpl()`,会遇到 `NullPointerException` 错误,因为所有通过 `@Resource` 注解注入的依赖都是 `null`。 + +## 解决方案 + +### 方式1:运行 YoloTrainMain.java(推荐) + +`YoloTrainMain.java` 已经修改为自动启动Spring环境并正确注入依赖。 + +**步骤:** +1. 打开 `YoloTrainMain.java` +2. 确保主启动类路径正确(第32行) +3. 修改配置参数(Python路径、虚拟环境、数据集等) +4. 直接运行 `main` 方法 + +**代码示例:** +```java +public static void main(String[] args) { + // 启动Spring应用上下文 + ConfigurableApplicationContext context = new SpringApplicationBuilder( + cn.iocoder.yudao.server.YudaoServerApplication.class) // 确保这个路径正确 + .headless(false) + .run(args); + + // 从Spring容器获取YoloOperationService + YoloOperationService yoloService = context.getBean(YoloOperationService.class); + + // 配置参数... + YoloConfig config = new YoloConfig(); + config.setTaskType("detect"); + config.setModelPath("D:/models/model.pt"); + config.setDatasetPath("D:/datasets/data.yaml"); + // ... 其他配置 + + // 启动训练 + CompletableFuture future = + yoloService.trainAsyncWithConfig("python", "yolo", config, "/path/to/log.txt"); + + // 等待完成 + future.join(); + + // 关闭Spring上下文 + context.close(); +} +``` + +### 方式2:在Spring Service中使用(推荐用于生产环境) + +如果你的代码已经在Spring环境中运行,直接注入即可。 + +**代码示例:** +```java +@Service +public class MyTrainingService { + + @Resource + private YoloOperationService yoloOperationService; + + public void startTraining() { + YoloConfig config = new YoloConfig(); + config.setTaskType("detect"); + config.setModelPath("D:/models/model.pt"); + config.setDatasetPath("D:/datasets/data.yaml"); + config.setEpochs(100); + config.setBatchSize(16); + config.setOutputPath("D:/yolo_output"); + + String logFilePath = "D:/yolo_output/training_log.txt"; + + CompletableFuture future = + yoloOperationService.trainAsyncWithConfig("python", "yolo", config, logFilePath); + + // 异步处理结果 + future.thenAccept(result -> { + if ("completed".equals(result.getStatus())) { + log.info("训练成功完成"); + log.info("导出路径: {}", result.getExportPath()); + } else { + log.error("训练失败: {}", result.getErrorMessage()); + } + }); + } +} +``` + +### 方式3:在Controller中使用(Web应用) + +如果通过Web API调用训练功能: + +```java +@RestController +@RequestMapping("/api/yolo") +public class YoloController { + + @Resource + private YoloOperationService yoloOperationService; + + @PostMapping("/train") + public CommonResult startTraining(@RequestBody YoloConfig config) { + try { + // 设置默认参数 + if (config.getEpochs() == 0) { + config.setEpochs(100); + } + if (config.getBatchSize() == 0) { + config.setBatchSize(16); + } + + String logFilePath = config.getOutputPath() + "/training_log.txt"; + + // 启动异步训练 + CompletableFuture future = + yoloOperationService.trainAsyncWithConfig( + "python", "yolo", config, logFilePath); + + // 返回任务ID + return success(future.get().getTaskId()); + + } catch (Exception e) { + return fail("训练启动失败: " + e.getMessage()); + } + } +} +``` + +## 常见问题 + +### Q1: 启动时找不到 YudaoServerApplication 类? + +**A:** 修改 `YoloTrainMain.java` 第32行,改为你的主启动类路径: +```java +// 修改为你的主启动类 +ConfigurableApplicationContext context = new SpringApplicationBuilder( + com.your.package.YourApplication.class) // 修改这里 + .headless(false) + .run(args); +``` + +### Q2: 如何查找主启动类? + +**A:** 主启动类通常有 `@SpringBootApplication` 注解,例如: +```java +@SpringBootApplication +public class YudaoServerApplication { + public static void main(String[] args) { + SpringApplication.run(YudaoServerApplication.class, args); + } +} +``` + +### Q3: 可以不启动Spring环境吗? + +**A:** 不推荐。`YoloOperationServiceImpl` 依赖多个服务(PythonVirtualEnvService, TrainService等),这些服务都需要Spring容器管理。 + +### Q4: 训练任务会阻塞主线程吗? + +**A:** 不会。`trainAsyncWithConfig` 返回 `CompletableFuture`,训练在异步线程中执行。 + +### Q5: 如何查看训练进度? + +**A:** 可以通过以下方式: +1. 查看日志文件 `training_log.txt` +2. 使用 `TrainResult` 对象的 `progress` 字段(需要通过轮询或回调) +3. 添加一个查询接口,返回训练状态 + +## 完整示例:Spring Controller + 训练进度查询 + +```java +@RestController +@RequestMapping("/api/yolo") +public class YoloTrainingController { + + @Resource + private YoloOperationService yoloOperationService; + + // 存储训练任务(生产环境建议使用数据库或Redis) + private static final Map trainingTasks = new ConcurrentHashMap<>(); + + /** + * 启动训练 + */ + @PostMapping("/train/start") + public CommonResult startTraining(@RequestBody YoloConfig config) { + try { + // 参数校验 + if (config.getDatasetPath() == null || config.getDatasetPath().isEmpty()) { + return fail("数据集路径不能为空"); + } + + // 设置日志文件路径 + String logFilePath = config.getOutputPath() + "/training_log_" + + System.currentTimeMillis() + ".txt"; + + // 启动异步训练 + CompletableFuture future = + yoloOperationService.trainAsyncWithConfig( + "python", "yolo", config, logFilePath); + + // 异步处理结果 + future.thenAccept(result -> { + String taskId = result.getTaskId(); + trainingTasks.put(taskId, result); + + if ("completed".equals(result.getStatus())) { + log.info("训练完成: taskId={}, exportPath={}", + taskId, result.getExportPath()); + } else if ("failed".equals(result.getStatus())) { + log.error("训练失败: taskId={}, error={}", + taskId, result.getErrorMessage()); + } + }); + + // 返回任务ID(先使用临时ID,实际应该从future中获取) + String taskId = UUID.randomUUID().toString(); + return success(taskId); + + } catch (Exception e) { + return fail("训练启动失败: " + e.getMessage()); + } + } + + /** + * 查询训练进度 + */ + @GetMapping("/train/progress/{taskId}") + public CommonResult getProgress(@PathVariable String taskId) { + TrainResult result = trainingTasks.get(taskId); + if (result == null) { + return fail("任务不存在"); + } + return success(result); + } + + /** + * 获取训练日志 + */ + @GetMapping("/train/log/{taskId}") + public CommonResult getLog(@PathVariable String taskId) { + TrainResult result = trainingTasks.get(taskId); + if (result == null) { + return fail("任务不存在"); + } + + try { + String logFilePath = "D:/yolo_output/training_log_" + taskId + ".txt"; + String logContent = Files.readString(Path.of(logFilePath)); + return success(logContent); + } catch (Exception e) { + return fail("读取日志失败: " + e.getMessage()); + } + } +} +``` + +## 总结 + +✅ **推荐方式**:使用 `YoloTrainMain.java`,它已经配置好Spring环境 +✅ **生产环境**:在你的Service或Controller中注入 `YoloOperationService` +✅ **不要**:直接 `new YoloOperationServiceImpl()` + +如有问题,请检查: +1. 主启动类路径是否正确 +2. Spring配置是否正确 +3. 依赖的服务是否都正常启动 diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YOLO训练使用说明.md b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YOLO训练使用说明.md new file mode 100644 index 0000000..2e5c904 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YOLO训练使用说明.md @@ -0,0 +1,312 @@ +# YOLO训练使用说明 + +## 概述 + +新的YOLO训练方法 `trainAsyncWithConfig` 提供了更灵活的配置选项,支持检测和分类任务,并改进了日志处理。 + +## 主要特性 + +1. **支持多种任务类型** + - 目标检测 (detect) + - 图像分类 (classify) + - 实例分割 (segment) + +2. **灵活的模型配置** + - 使用预训练模型 (yolov8n, yolov8s, etc.) + - 使用自定义模型文件 (.pt 或 .yaml) + +3. **完整的训练参数** + - epochs, batch, imgsz + - learningRate, device, optimizer + - workers, savePeriod + +4. **改进的日志处理** + - 自动过滤进度条信息 + - 保留每轮批次信息 + - 写入到txt文件 + +5. **导出路径记录** + - 自动保存模型导出文件夹路径 + +## 使用方法 + +### 方法一:直接运行 Main 函数 + +1. 打开 `YoloTrainMain.java` +2. 修改配置参数 +3. 运行 `main` 方法 + +```java +public static void main(String[] args) { + // 配置参数 + String pythonPath = "python"; + String envName = "yolo"; + + YoloConfig config = new YoloConfig(); + config.setTaskType("detect"); + config.setModelName("yolov8n"); + config.setDatasetPath("D:/datasets/dataset.yaml"); + config.setEpochs(100); + config.setBatchSize(16); + config.setImageSize(640); + config.setOutputPath("D:/yolo_output"); + + String logFilePath = "D:/yolo_output/training_log.txt"; + + // 执行训练 + YoloOperationServiceImpl service = new YoloOperationServiceImpl(); + CompletableFuture future = + service.trainAsyncWithConfig(pythonPath, envName, config, logFilePath); + + // 等待结果 + future.thenAccept(result -> { + System.out.println("训练完成: " + result.getStatus()); + System.out.println("导出路径: " + result.getExportPath()); + }); + + future.join(); +} +``` + +### 方法二:在Spring中使用 + +```java +@Service +public class MyTrainingService { + + @Resource + private YoloOperationService yoloOperationService; + + public void startTraining() { + YoloConfig config = new YoloConfig(); + config.setTaskType("detect"); + config.setModelName("yolov8n"); + config.setDatasetPath("/path/to/dataset.yaml"); + config.setEpochs(100); + config.setBatchSize(16); + config.setImageSize(640); + config.setOutputPath("/path/to/output"); + + String logFilePath = "/path/to/training_log.txt"; + + CompletableFuture future = + yoloOperationService.trainAsyncWithConfig("python", "yolo", config, logFilePath); + + // 异步处理结果 + future.thenAccept(result -> { + if ("completed".equals(result.getStatus())) { + log.info("训练成功完成"); + log.info("导出路径: {}", result.getExportPath()); + log.info("最终指标: {}", result.getMetrics()); + } else { + log.error("训练失败: {}", result.getErrorMessage()); + } + }); + } +} +``` + +## 参数配置详解 + +### 任务类型 (taskType) + +- **detect**: 目标检测任务 +- **classify**: 图像分类任务 +- **segment**: 实例分割任务 + +### 模型配置 + +**方式1: 预训练模型** +```java +config.setModelName("yolov8n"); // n, s, m, l, x +``` + +**方式2: 自定义模型** +```java +config.setModelPath("D:/models/custom_model.pt"); +``` + +### 数据集配置 + +数据集配置文件 (.yaml) 应包含: +- 训练和验证数据路径 +- 类别名称和数量 +- 数据集路径 + +```yaml +# dataset.yaml 示例 +path: /path/to/dataset +train: images/train +val: images/val +names: + 0: person + 1: car + 2: dog +``` + +### 训练参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| epochs | int | 100 | 训练轮数 | +| batchSize | int | 16 | 批次大小 | +| imageSize | int | 640 | 图像大小 | +| learningRate | double | 0.01 | 学习率 | +| device | String | "0" | 设备配置 | +| optimizer | String | "SGD" | 优化器 | +| workers | int | 8 | 工作线程数 | +| savePeriod | int | -1 | 保存间隔 | + +### 设备配置 + +- `"0"`, `"1"`, `"2"`: 使用指定GPU +- `"0,1"`, `"0,1,2"`: 使用多GPU +- `"cpu"`: 使用CPU + +## 日志说明 + +### 自动过滤 + +- ✅ 保留:每轮批次信息 +- ✅ 保留:训练指标(mAP, precision, recall) +- ❌ 过滤:进度条信息(包含%的行,但保留100%) +- ✅ 保留:模型保存路径信息 + +### 日志文件 + +日志文件包含: +- 训练开始时间 +- 每轮训练信息(不包含进度条) +- 最终训练指标 +- 模型保存路径 +- 训练完成时间 + +## 训练结果 + +训练完成后,可以通过 `TrainResult` 对象获取: + +```java +TrainResult result = future.get(); + +// 状态信息 +String status = result.getStatus(); // pending, running, completed, failed +int progress = result.getProgress(); // 0-100 +String errorMessage = result.getErrorMessage(); // 错误信息(如果有) + +// 训练指标 +int currentEpoch = result.getCurrentEpoch(); // 当前轮次 +double loss = result.getLoss(); // 损失值 +double precision = result.getPrecision(); // 精度 +double recall = result.getRecall(); // 召回率 +double map = result.getMap(); // mAP值 + +// 输出信息 +String exportPath = result.getExportPath(); // 模型导出路径 +String metrics = result.getMetrics(); // 详细指标 +String trainingSummary = result.getTrainingSummary(); // 训练摘要 +``` + +## 示例场景 + +### 场景1: 目标检测训练 + +```java +YoloConfig config = new YoloConfig(); +config.setTaskType("detect"); +config.setModelName("yolov8s"); +config.setDatasetPath("D:/datasets/coco.yaml"); +config.setEpochs(100); +config.setBatchSize(16); +config.setImageSize(640); +config.setDevice("0"); +``` + +### 场景2: 图像分类训练 + +```java +YoloConfig config = new YoloConfig(); +config.setTaskType("classify"); +config.setModelName("yolov8n-cls.pt"); +config.setDatasetPath("D:/datasets/imagenet.yaml"); +config.setEpochs(50); +config.setBatchSize(32); +config.setImageSize(224); +config.setDevice("0"); +``` + +### 场景3: 使用自定义模型 + +```java +YoloConfig config = new YoloConfig(); +config.setTaskType("detect"); +config.setModelPath("D:/models/my_custom_model.pt"); +config.setDatasetPath("D:/datasets/custom.yaml"); +config.setEpochs(100); +config.setBatchSize(8); +config.setImageSize(1024); +config.setLearningRate(0.001); +config.setOptimizer("Adam"); +``` + +### 场景4: 多GPU训练 + +```java +YoloConfig config = new YoloConfig(); +config.setTaskType("detect"); +config.setModelName("yolov8x"); +config.setDatasetPath("/datasets/large_dataset.yaml"); +config.setEpochs(100); +config.setBatchSize(32); +config.setImageSize(1280); +config.setDevice("0,1,2,3"); // 使用4个GPU +``` + +## 常见问题 + +### Q1: 如何查看训练进度? + +A: 日志文件中会显示每轮的训练信息,但不包含进度条。可以通过 `TrainResult` 对象的 `progress` 字段查看总体进度。 + +### Q2: 如何恢复中断的训练? + +A: 设置 `savePeriod` 参数来定期保存模型,然后使用保存的模型文件继续训练: +```java +config.setModelPath("D:/yolo_output/train/weights/last.pt"); +config.setEpochs(50); // 继续训练50轮 +``` + +### Q3: 如何调整学习率? + +A: 设置 `learningRate` 参数: +```java +config.setLearningRate(0.001); // 降低学习率 +``` + +### Q4: 如何在Linux系统上使用? + +A: 修改 Python 路径和虚拟环境名称: +```java +String pythonPath = "/usr/bin/python3"; +String envName = "yolo_env"; +``` + +### Q5: 日志文件太大怎么办? + +A: 该方法已自动过滤进度条信息,大大减少了日志大小。如果还需要进一步减少,可以: +- 增加 `savePeriod` 减少日志输出频率 +- 使用 `verbose=False` 参数(需要在命令中添加) + +## 注意事项 + +1. 确保Python虚拟环境已安装YOLO +2. 确保数据集配置文件路径正确 +3. 确保输出路径有写入权限 +4. GPU训练时确保有足够的显存 +5. 大型数据集建议使用更大的batch size和workers + +## 技术支持 + +如有问题,请查看: +1. 日志文件中的错误信息 +2. YOLO官方文档 +3. 项目README文档 diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YoloOperationService.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YoloOperationService.java index 7496681..aca2076 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YoloOperationService.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YoloOperationService.java @@ -22,6 +22,17 @@ public interface YoloOperationService { */ CompletableFuture trainAsync(String pythonPath, String envName, YoloConfig config); + /** + * YOLO训练(异步,新方法,支持检测和分类任务) + * + * @param pythonPath Python路径 + * @param envName 虚拟环境名称 + * @param config 训练配置 + * @param logFilePath 日志文件路径 + * @return 异步训练结果 + */ + CompletableFuture trainAsyncWithConfig(String pythonPath, String envName, YoloConfig config, String logFilePath); + /** * YOLO验证 * diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YoloOperationServiceImpl.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YoloOperationServiceImpl.java index 156ed6f..bb673d7 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YoloOperationServiceImpl.java +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YoloOperationServiceImpl.java @@ -6,8 +6,8 @@ import cn.iocoder.yudao.module.annotation.dal.dataobject.train.TrainDO; import cn.iocoder.yudao.module.annotation.dal.dataobject.trainresult.TrainResultDO; import cn.iocoder.yudao.module.annotation.dal.yolo.YoloConfig; import cn.iocoder.yudao.module.annotation.service.python.PythonVirtualEnvService; +import cn.iocoder.yudao.module.annotation.service.python.PythonVirtualEnvServiceImpl; import cn.iocoder.yudao.module.annotation.service.python.vo.PythonVirtualEnvInfo; - import cn.iocoder.yudao.module.annotation.service.train.TrainService; import cn.iocoder.yudao.module.annotation.service.traininfo.TrainInfoService; import cn.iocoder.yudao.module.annotation.service.trainresult.TrainResultService; @@ -17,10 +17,6 @@ import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; -import cn.iocoder.yudao.module.annotation.service.traininfo.TrainInfoService; -import cn.iocoder.yudao.module.annotation.service.trainresult.TrainResultService; -import jakarta.annotation.Resource; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.io.BufferedReader; @@ -1749,7 +1745,408 @@ public class YoloOperationServiceImpl implements YoloOperationService { } } + /** + * 新的异步训练方法,支持检测和分类任务 + * + * @param pythonPath Python可执行文件路径 + * @param envName 虚拟环境名称 + * @param config YOLO配置对象 + * @param logFilePath 日志文件路径 + * @return 异步Future + */ + @Override + public CompletableFuture trainAsyncWithConfig(String pythonPath, String envName, YoloConfig config, String logFilePath) { + return CompletableFuture.supplyAsync(() -> { + TrainResult result = new TrainResult(); + result.setStatus("running"); + result.setProgress(0); + + // 生成任务ID + String taskId = UUID.randomUUID().toString(); + result.setTaskId(taskId); + if (pythonVirtualEnvService == null){ + pythonVirtualEnvService = new PythonVirtualEnvServiceImpl(); + } + + try { + // 检测虚拟环境 + PythonVirtualEnvInfo envInfo = pythonVirtualEnvService.activateVirtualEnv(pythonPath, envName); +// if (!envInfo.getVirtualEnvExists() || !envInfo.getYoloInstalled()) { +// result.setStatus("failed"); +// result.setErrorMessage("虚拟环境不存在或未安装YOLO"); +// return result; +// } + + // 构建训练命令 + String command = buildTrainCommandWithConfig(envInfo, config); + log.info("开始YOLO训练(新方法),命令: {}", command); + + // 创建日志文件目录 + if (logFilePath != null && !logFilePath.isEmpty()) { + java.io.File logFile = new java.io.File(logFilePath); + java.io.File logDir = logFile.getParentFile(); + if (logDir != null && !logDir.exists()) { + logDir.mkdirs(); + } + } + + // 执行训练 + ProcessBuilder processBuilder = new ProcessBuilder(); + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + processBuilder.command("cmd", "/c", command); + processBuilder.environment().put("PYTHONIOENCODING", "utf-8"); + processBuilder.environment().put("LANG", "zh_CN.UTF-8"); + } else { + processBuilder.command("/bin/bash", "-c", command); + processBuilder.environment().put("PYTHONIOENCODING", "utf-8"); + } + Process process = processBuilder.start(); + + BufferedReader stdoutReader = new BufferedReader(new InputStreamReader(process.getInputStream())); + BufferedReader stderrReader = new BufferedReader(new InputStreamReader(process.getErrorStream())); + + // 写入日志文件 + java.io.PrintWriter logWriter = null; + if (logFilePath != null && !logFilePath.isEmpty()) { + try { + logWriter = new java.io.PrintWriter(new java.io.FileWriter(logFilePath, java.nio.charset.StandardCharsets.UTF_8)); + } catch (Exception e) { + log.error("创建日志文件失败: {}", logFilePath, e); + } + } + + StringBuilder fullOutput = new StringBuilder(); + String stdoutLine; + String stderrLine = null; + + // 记录上一轮的epoch,用于保存每轮信息 + int[] lastSavedEpoch = {0}; + + // 同时读取标准输出和错误输出 + while ((stdoutLine = stdoutReader.readLine()) != null || (stderrLine = stderrReader.readLine()) != null) { + if (stdoutLine != null) { + log.info("YOLO训练日志: {}", stdoutLine); + fullOutput.append(stdoutLine).append("\n"); + + // 过滤掉包含百分号的进度行(但保留100%完成行) + boolean isProgressLine = stdoutLine.contains("%") && !stdoutLine.contains("100%"); + if (!isProgressLine || stdoutLine.contains("Results saved to") || stdoutLine.contains("mAP") || + stdoutLine.contains("precision") || stdoutLine.contains("recall")) { + // 写入日志文件 + if (logWriter != null) { + logWriter.println(stdoutLine); + logWriter.flush(); + } + } + + // 解析训练结果 + parseTrainingResult(stdoutLine, config, result, lastSavedEpoch); + detectAndParseResultsSaved(stdoutLine, config, null); // config中有trainId + } + + if (stderrLine != null) { + log.info("YOLO训练日志: {}", stderrLine); + fullOutput.append(stderrLine).append("\n"); + + // 过滤掉包含百分号的进度行 + boolean isProgressLine = stderrLine.contains("%") && !stderrLine.contains("100%"); + if (!isProgressLine || stderrLine.contains("Results saved to") || stderrLine.contains("mAP") || + stderrLine.contains("precision") || stderrLine.contains("recall")) { + // 写入日志文件 + if (logWriter != null) { + logWriter.println(stderrLine); + logWriter.flush(); + } + } + + // 解析训练结果 + parseTrainingResult(stderrLine, config, result, lastSavedEpoch); + detectAndParseResultsSaved(stderrLine, config, null); + } + } + + // 关闭日志文件 + if (logWriter != null) { + logWriter.close(); + } + + int exitCode = process.waitFor(); + stdoutReader.close(); + stderrReader.close(); + + if (exitCode == 0) { + result.setStatus("completed"); + result.setProgress(100); + result.setLogMessage("训练完成"); + + // 保存导出文件夹路径 + if (config.getOutputPath() != null) { + result.setExportPath(config.getOutputPath()); + } + + // 解析最终训练结果 + parseFinalResults(fullOutput.toString(), config); + result.setMetrics(config.getMetrics()); + result.setTrainingSummary(config.getTrainingSummary()); + + log.info("训练完成,日志文件: {}, 导出路径: {}", logFilePath, result.getExportPath()); + } else { + result.setStatus("failed"); + result.setErrorMessage("训练失败,退出码: " + exitCode); + result.setLogMessage(fullOutput.toString()); + } + + } catch (Exception e) { + log.error("YOLO训练异常", e); + result.setStatus("failed"); + result.setErrorMessage("训练异常: " + e.getMessage()); + } + + return result; + }, asyncExecutor); + } + + /** + * 构建训练命令(新方法,支持更多配置) + */ + private String buildTrainCommandWithConfig(PythonVirtualEnvInfo envInfo, YoloConfig config) { + StringBuilder command = new StringBuilder(); + + // 构建Python命令 + if ("conda".equals(envInfo.getVirtualEnvType())) { + command.append("conda run -n ").append(new File(envInfo.getVirtualEnvPath()).getName()).append(" "); + } else { + // 使用虚拟环境的Python路径(可在此处修改为你的Python路径) + command.append(envInfo.getVirtualEnvPythonPath()).append(" "); + } + + // 规范化路径 + String dataPath = config.getDatasetPath() != null ? normalizePath(config.getDatasetPath()) : ""; + String outputPath = config.getOutputPath() != null ? normalizePath(config.getOutputPath()) : "runs"; + String modelPath = config.getModelPath() != null ? normalizePath(config.getModelPath()) : ""; + + command.append("-c \""); + command.append("import sys; sys.path.insert(0, '.'); "); + command.append("from ultralytics import YOLO; "); + + // 构建模型路径 + if (modelPath != null && !modelPath.trim().isEmpty()) { + command.append("model = YOLO(r'").append(escapePythonString(modelPath)).append("'); "); + } else { + command.append("model = YOLO('").append(config.getModelName()).append("'); "); + } + + // 构建训练命令 + command.append("model.train("); + + // data参数(数据集配置文件) + command.append("data=r'").append(escapePythonString(dataPath)).append("', "); + + // 任务类型(检测或分类) + if (config.getTaskType() != null && !config.getTaskType().isEmpty()) { + command.append("task='").append(config.getTaskType()).append("', "); + } + + // epochs参数 + command.append("epochs=").append(config.getEpochs()).append(", "); + + // batch参数 + command.append("batch=").append(config.getBatchSize()).append(", "); + + // imgsz参数(图像大小) + command.append("imgsz=").append(config.getImageSize()).append(", "); + + // device参数 + command.append("device='").append(config.getDevice()).append("', "); + + // 学习率 + if (config.getLearningRate() > 0) { + command.append("lr0=").append(config.getLearningRate()).append(", "); + } + + // 优化器 + if (config.getOptimizer() != null && !config.getOptimizer().isEmpty()) { + command.append("optimizer='").append(config.getOptimizer()).append("', "); + } + + // 工作线程数 + if (config.getWorkers() > 0) { + command.append("workers=").append(config.getWorkers()).append(", "); + } + + // 保存间隔 + if (config.getSavePeriod() > 0) { + command.append("save_period=").append(config.getSavePeriod()).append(", "); + } + + // 输出路径 + command.append("project=r'").append(escapePythonString(outputPath)).append("'"); + + command.append(")\""); + + return command.toString(); + } + + /** + * 解析训练结果(简化版,只保存重要信息) + */ + private void parseTrainingResult(String line, YoloConfig config, TrainResult result, int[] lastSavedEpoch) { + try { + String trimmedLine = line.trim(); + // 解析Epoch信息 + if (trimmedLine.contains("Epoch") || (trimmedLine.matches("\\d+/\\d+"))) { + String[] parts = trimmedLine.split("\\s+"); + for (String part : parts) { + if (part.contains("/")) { + String[] epochParts = part.split("/"); + if (epochParts.length == 2) { + try { + int currentEpoch = Integer.parseInt(epochParts[0]); + int totalEpochs = Integer.parseInt(epochParts[1]); + config.setCurrentEpoch(currentEpoch); + result.setCurrentEpoch(currentEpoch); + + // 计算进度 + int progress = (int) ((double) currentEpoch / totalEpochs * 90); + result.setProgress(progress); + config.setProgress(progress); + + // 当epoch完成时保存 + if (currentEpoch > lastSavedEpoch[0] && currentEpoch <= config.getEpochs()) { + lastSavedEpoch[0] = currentEpoch; + } + } catch (NumberFormatException e) { + // 忽略解析错误 + } + } + } + } + } + // 解析损失值和指标(过滤掉进度行) + if (trimmedLine.contains("loss") || trimmedLine.contains("precision") || + trimmedLine.contains("recall") || trimmedLine.contains("mAP")) { + // 不处理包含百分号的行 + if (!trimmedLine.contains("%") || trimmedLine.contains("100%")) { + // 解析loss + if (trimmedLine.contains("loss:") && !trimmedLine.contains("box_loss") && + !trimmedLine.contains("cls_loss") && !trimmedLine.contains("dfl_loss")) { + try { + String lossStr = trimmedLine.substring(trimmedLine.indexOf("loss:") + 5).trim(); + lossStr = lossStr.split(",")[0].trim(); + double loss = Double.parseDouble(lossStr); + config.setLoss(loss); + result.setLoss(loss); + } catch (Exception e) { + // 忽略解析错误 + } + } + + // 解析precision + if (trimmedLine.contains("precision:")) { + try { + String precisionStr = trimmedLine.substring(trimmedLine.indexOf("precision:") + 10).trim(); + precisionStr = precisionStr.split(",")[0].trim(); + double precision = Double.parseDouble(precisionStr); + config.setPrecision(precision); + result.setPrecision(precision); + } catch (Exception e) { + // 忽略解析错误 + } + } + + // 解析recall + if (trimmedLine.contains("recall:")) { + try { + String recallStr = trimmedLine.substring(trimmedLine.indexOf("recall:") + 7).trim(); + recallStr = recallStr.split(",")[0].trim(); + double recall = Double.parseDouble(recallStr); + config.setRecall(recall); + result.setRecall(recall); + } catch (Exception e) { + // 忽略解析错误 + } + } + + // 解析mAP + if (trimmedLine.contains("mAP50-95") || (trimmedLine.contains("mAP") && !trimmedLine.contains("mAP50"))) { + try { + String mapStr = trimmedLine.substring(trimmedLine.indexOf(":") + 1).trim(); + mapStr = mapStr.split(",")[0].trim(); + double map = Double.parseDouble(mapStr); + config.setMap(map); + result.setMap(map); + } catch (Exception e) { + // 忽略解析错误 + } + } + } + } + + // 解析任务类型 + if (trimmedLine.contains("Classification") || trimmedLine.contains("classify")) { + config.setTaskType("classify"); + result.setTaskType("classify"); + } else if (trimmedLine.contains("Detection") || trimmedLine.contains("detect")) { + config.setTaskType("detect"); + result.setTaskType("detect"); + } + + } catch (Exception e) { + log.debug("解析训练结果时出错: {}", line, e); + } + } + + /** + * 训练结果类 + */ + public static class TrainResult { + private String taskId; + private String status; + private int progress; + private String logMessage; + private String errorMessage; + private String metrics; + private String trainingSummary; + private String exportPath; + private int currentEpoch; + private double loss; + private double precision; + private double recall; + private double map; + private String taskType; + + // Getters and Setters + public String getTaskId() { return taskId; } + public void setTaskId(String taskId) { this.taskId = taskId; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public int getProgress() { return progress; } + public void setProgress(int progress) { this.progress = progress; } + public String getLogMessage() { return logMessage; } + public void setLogMessage(String logMessage) { this.logMessage = logMessage; } + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + public String getMetrics() { return metrics; } + public void setMetrics(String metrics) { this.metrics = metrics; } + public String getTrainingSummary() { return trainingSummary; } + public void setTrainingSummary(String trainingSummary) { this.trainingSummary = trainingSummary; } + public String getExportPath() { return exportPath; } + public void setExportPath(String exportPath) { this.exportPath = exportPath; } + public int getCurrentEpoch() { return currentEpoch; } + public void setCurrentEpoch(int currentEpoch) { this.currentEpoch = currentEpoch; } + public double getLoss() { return loss; } + public void setLoss(double loss) { this.loss = loss; } + public double getPrecision() { return precision; } + public void setPrecision(double precision) { this.precision = precision; } + public double getRecall() { return recall; } + public void setRecall(double recall) { this.recall = recall; } + public double getMap() { return map; } + public void setMap(double map) { this.map = map; } + public String getTaskType() { return taskType; } + public void setTaskType(String taskType) { this.taskType = taskType; } + } } \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YoloTrainConfig.java b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YoloTrainConfig.java new file mode 100644 index 0000000..dc9da01 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/java/cn/iocoder/yudao/module/annotation/service/yolo/YoloTrainConfig.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.annotation.service.yolo; + +/** + * YOLO训练配置类 + */ +public class YoloTrainConfig { + private String modelPath; // 模型路径 + private String datasetPath; // 数据集路径 + private int epochs; // 训练轮数 + private int batchSize; // 批次大小 + private int imageSize; // 图像大小 + private String device; // 设备 + private String outputPath; // 输出路径 + private String logFilePath; // 日志文件路径 + private int projectId; // 项目编号 + private String venvPath; // 虚拟环境路径(Scripts或bin的上一级) + + public YoloTrainConfig(String modelPath, String datasetPath, int epochs, int batchSize, int imageSize, + String device, String outputPath, String logFilePath, int projectId, String venvPath) { + this.modelPath = modelPath; + this.datasetPath = datasetPath; + this.epochs = epochs; + this.batchSize = batchSize; + this.imageSize = imageSize; + this.device = device; + this.outputPath = outputPath; + this.logFilePath = logFilePath; + this.projectId = projectId; + this.venvPath = venvPath; + } + + // Getters and Setters + public String getModelPath() { return modelPath; } + public String getDatasetPath() { return datasetPath; } + public int getEpochs() { return epochs; } + public int getBatchSize() { return batchSize; } + public int getImageSize() { return imageSize; } + public String getDevice() { return device; } + public String getOutputPath() { return outputPath; } + public String getLogFilePath() { return logFilePath; } + public int getProjectId() { return projectId; } + public String getVenvPath() { return venvPath; } +} diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/resources/mapper/dataset/DatasetMapper.xml b/yudao-module-annotation/yudao-module-annotation-biz/src/main/resources/mapper/dataset/DatasetMapper.xml new file mode 100644 index 0000000..fe7230a --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/resources/mapper/dataset/DatasetMapper.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/resources/mapper/mark/MarkMapper.xml b/yudao-module-annotation/yudao-module-annotation-biz/src/main/resources/mapper/mark/MarkMapper.xml index e9c6920..c96bfb3 100644 --- a/yudao-module-annotation/yudao-module-annotation-biz/src/main/resources/mapper/mark/MarkMapper.xml +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/resources/mapper/mark/MarkMapper.xml @@ -11,7 +11,12 @@ UPDATE annotation_mark - SET status = #{status} + + status = #{status}, + path = #{path}, + image_path = #{imagePath}, + label_path = #{labelPath}, + WHERE id = #{id} \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/main/resources/mapper/model/ModelMapper.xml b/yudao-module-annotation/yudao-module-annotation-biz/src/main/resources/mapper/model/ModelMapper.xml new file mode 100644 index 0000000..e095a2f --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/main/resources/mapper/model/ModelMapper.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/yudao-module-annotation/yudao-module-annotation-biz/src/test/java/cn/iocoder/yudao/module/annotation/service/model/ModelServiceImplTest.java b/yudao-module-annotation/yudao-module-annotation-biz/src/test/java/cn/iocoder/yudao/module/annotation/service/model/ModelServiceImplTest.java new file mode 100644 index 0000000..c25f599 --- /dev/null +++ b/yudao-module-annotation/yudao-module-annotation-biz/src/test/java/cn/iocoder/yudao/module/annotation/service/model/ModelServiceImplTest.java @@ -0,0 +1,128 @@ +package cn.iocoder.yudao.module.annotation.service.model; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.annotation.controller.admin.model.vo.ModelPageReqVO; +import cn.iocoder.yudao.module.annotation.controller.admin.model.vo.ModelSaveReqVO; +import cn.iocoder.yudao.module.annotation.dal.dataobject.model.ModelDO; +import cn.iocoder.yudao.module.annotation.dal.mysql.model.ModelMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime; +import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.cloneIgnoreId; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.annotation.enums.ErrorCodeConstants.MODEL_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link ModelServiceImpl} 的单元测试类 + * + * @author 管理员 + */ +@Import(ModelServiceImpl.class) +public class ModelServiceImplTest extends BaseDbUnitTest { + + @Resource + private ModelServiceImpl modelService; + + @Resource + private ModelMapper modelMapper; + + @Test + public void testCreateModel_success() { + // 准备参数 + ModelSaveReqVO createReqVO = randomPojo(ModelSaveReqVO.class).setId(null); + + // 调用 + Integer modelId = modelService.createModel(createReqVO); + // 断言 + assertNotNull(modelId); + // 校验记录的属性是否正确 + ModelDO model = modelMapper.selectById(modelId); + assertPojoEquals(createReqVO, model, "id"); + } + + @Test + public void testUpdateModel_success() { + // mock 数据 + ModelDO dbModel = randomPojo(ModelDO.class); + modelMapper.insert(dbModel);// @Sql: 先插入出一条存在的数据 + // 准备参数 + ModelSaveReqVO updateReqVO = randomPojo(ModelSaveReqVO.class, o -> { + o.setId(dbModel.getId()); // 设置更新的 ID + }); + + // 调用 + modelService.updateModel(updateReqVO); + // 校验是否更新正确 + ModelDO model = modelMapper.selectById(updateReqVO.getId()); // 获取最新的 + assertPojoEquals(updateReqVO, model); + } + + @Test + public void testUpdateModel_notExists() { + // 准备参数 + ModelSaveReqVO updateReqVO = randomPojo(ModelSaveReqVO.class); + + // 调用, 并断言异常 + assertServiceException(() -> modelService.updateModel(updateReqVO), MODEL_NOT_EXISTS); + } + + @Test + public void testDeleteModel_success() { + // mock 数据 + ModelDO dbModel = randomPojo(ModelDO.class); + modelMapper.insert(dbModel);// @Sql: 先插入出一条存在的数据 + // 准备参数 + Integer id = dbModel.getId(); + + // 调用 + modelService.deleteModel(id); + // 校验数据不存在了 + assertNull(modelMapper.selectById(id)); + } + + @Test + public void testDeleteModel_notExists() { + + + // 调用, 并断言异常 + assertServiceException(() -> modelService.deleteModel(1), MODEL_NOT_EXISTS); + } + + @Test + @Disabled // TODO 请修改 null 为需要的值,然后删除 @Disabled 注解 + public void testGetModelPage() { + // mock 数据 + ModelDO dbModel = randomPojo(ModelDO.class, o -> { // 等会查询到 + o.setName(null); + o.setCreateTime(null); + o.setType(null); + }); + modelMapper.insert(dbModel); + // 测试 name 不匹配 + modelMapper.insert(cloneIgnoreId(dbModel, o -> o.setName(null))); + // 测试 createTime 不匹配 + modelMapper.insert(cloneIgnoreId(dbModel, o -> o.setCreateTime(null))); + // 测试 type 不匹配 + modelMapper.insert(cloneIgnoreId(dbModel, o -> o.setType(null))); + // 准备参数 + ModelPageReqVO reqVO = new ModelPageReqVO(); + reqVO.setName(null); + reqVO.setCreateTime(buildBetweenTime(2023, 2, 1, 2023, 2, 28)); + reqVO.setType(null); + + // 调用 + PageResult pageResult = modelService.getModelPage(reqVO); + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(1, pageResult.getList().size()); + assertPojoEquals(dbModel, pageResult.getList().get(0)); + } + +} \ No newline at end of file diff --git a/yudao-module-camera/yudao-module-camera-biz/src/main/java/cn/iocoder/yudao/module/camera/dal/zlm/Mp4RecordFileResponse.java b/yudao-module-camera/yudao-module-camera-biz/src/main/java/cn/iocoder/yudao/module/camera/dal/zlm/Mp4RecordFileResponse.java new file mode 100644 index 0000000..1749ace --- /dev/null +++ b/yudao-module-camera/yudao-module-camera-biz/src/main/java/cn/iocoder/yudao/module/camera/dal/zlm/Mp4RecordFileResponse.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.camera.dal.zlm; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class Mp4RecordFileResponse { + + private int code; + private Mp4RecordFileData data; + + @Data + public static class Mp4RecordFileData { + private List paths; + private String rootPath; + } +} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileConfigDO.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileConfigDO.java index ac2d4d0..5d2d75c 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileConfigDO.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileConfigDO.java @@ -67,7 +67,7 @@ public class FileConfigDO extends BaseDO { @Override protected Object parse(String json) { - FileClientConfig config = JsonUtils.parseObjectQuietly(json, new TypeReference<>() {}); + FileClientConfig config = JsonUtils.parseObjectQuietly(json, new TypeReference() {}); if (config != null) { return config; } diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/kesc/StockControlController.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/kesc/StockControlController.java index 92f66a6..6fe1fde 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/kesc/StockControlController.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/kesc/StockControlController.java @@ -69,11 +69,51 @@ public class StockControlController { // 随行结束 plcService.orderStop(kescEntity.getTaskId()); } + return CommonResult.success("OK"); + } + + @PostMapping("/action") + @Operation(summary = "随行开始任务") + @ResponseBody + @PermitAll + public CommonResult action(@RequestBody KsecDataInfo kescEntity){ + log.info("随行开始任务:{}",kescEntity); +// kescEntity = kescEntity.fromBySRMNumber(kescEntity); + // 随行开始 + if (kescEntity.getCmdName().equals("B1")){ + plcService.orderStart(kescEntity); + }else if (kescEntity.getCmdName().contains("C")){ +// 随行正在进行 + plcService.action(kescEntity.getTaskId(),kescEntity.getCmdName()); + }else if (kescEntity.getCmdName().equals("B2")){ +// 随行结束 + plcService.orderStop(kescEntity.getTaskId()); + } return CommonResult.success("OK"); } + @PostMapping("/check") + @Operation(summary = "盘点任务") + @ResponseBody + @PermitAll + public CommonResult check(@RequestBody KsecDataInfo kescEntity){ + log.info("盘点任务:{}",kescEntity); +// kescEntity = kescEntity.fromBySRMNumber(kescEntity); + + // 开始盘点 + if (kescEntity.getCmdName().equals("E1")){ + plcService.checkStart(kescEntity); + plcService.check(kescEntity.getTaskId(),kescEntity.getCmdName()); + }else if (kescEntity.getCmdName().contains("E2")){ +// 到位盘点 + plcService.check(kescEntity.getTaskId(),kescEntity.getCmdName()); + + plcService.checkStop(kescEntity.getTaskId()); + } + return CommonResult.success("OK"); + } @PostMapping("/inventory") @Operation(summary = "盘点任务") @ResponseBody @@ -107,7 +147,6 @@ public class StockControlController { return CommonResult.success(kescEntity); } - @PostMapping("/resultReport") @Operation(summary = "随行盘点更新状态") @ResponseBody @@ -116,6 +155,7 @@ public class StockControlController { log.info("随行盘点更新状态:{}",kescEntity); return CommonResult.success(kescEntity); } + @PostMapping("/updateStatus") @Operation(summary = "随行盘点更新状态") @ResponseBody @@ -133,11 +173,6 @@ public class StockControlController { }else if (kescEntity.getCmdName().startsWith("C")){ plcService.action(kescEntity.getTaskId(),kescEntity.getCmdName()); } - return CommonResult.success("OK"); } - - - - } diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfPageReqVO.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfPageReqVO.java index 19ee115..dc17f88 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfPageReqVO.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfPageReqVO.java @@ -1,9 +1,10 @@ package cn.iocoder.yudao.module.camera.controller.admin.specificationConf.vo; -import lombok.*; -import java.util.*; -import io.swagger.v3.oas.annotations.media.Schema; import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; @Schema(description = "管理后台 - 品规配置分页 Request VO") @Data @@ -23,6 +24,9 @@ public class SpecificationConfPageReqVO extends PageParam { @Schema(description = "最小面积") private Integer minArea; + @Schema(description = "单层个数") + private Integer layersNumber; + @Schema(description = "截取高度") private Integer tolerance; diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfRespVO.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfRespVO.java index 97000fb..7b5f1d3 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfRespVO.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfRespVO.java @@ -29,6 +29,11 @@ public class SpecificationConfRespVO { @ExcelProperty("最小面积") private Integer minArea; + + @Schema(description = "单层个数") + @ExcelProperty("单层个数") + private Integer layersNumber; + @Schema(description = "截取高度") @ExcelProperty("截取高度") private Integer tolerance; diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfSaveReqVO.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfSaveReqVO.java index e54e727..72cae86 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfSaveReqVO.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/controller/admin/specificationConf/vo/SpecificationConfSaveReqVO.java @@ -16,6 +16,10 @@ public class SpecificationConfSaveReqVO { @Schema(description = "整堆高度单位mm") private Integer height; + + @Schema(description = "单层个数") + private Integer layersNumber; + @Schema(description = "最小面积") private Integer minArea; diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/dal/dataobject/specificationConf/SpecificationConfDO.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/dal/dataobject/specificationConf/SpecificationConfDO.java index a9dcf8d..8730fbf 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/dal/dataobject/specificationConf/SpecificationConfDO.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/dal/dataobject/specificationConf/SpecificationConfDO.java @@ -36,6 +36,10 @@ public class SpecificationConfDO extends BaseDO { * 最小面积 */ private Integer minArea; + /** + * 单层个数 + */ + private Integer layersNumber; /** * 截取高度 */ diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/Hik3D/HikFlaskApiService.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/Hik3D/HikFlaskApiService.java index 620453f..de4beb1 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/Hik3D/HikFlaskApiService.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/Hik3D/HikFlaskApiService.java @@ -10,12 +10,20 @@ import cn.iocoder.yudao.module.camera.service.resources.URLResourcesService; import cn.iocoder.yudao.module.camera.service.scan.ScanService; import cn.iocoder.yudao.module.system.service.dict.DictDataService; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializer; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import okhttp3.*; import org.springframework.stereotype.Service; import java.io.IOException; +import java.lang.reflect.Type; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.Objects; @Slf4j @@ -25,7 +33,25 @@ public class HikFlaskApiService implements ScanService { private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); @Resource private OkHttpClient client; - private final Gson gson = new Gson(); + + private final Gson gson; + + public HikFlaskApiService() { + // 配置 Gson 支持 Java 8 时间类型 + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + this.gson = new GsonBuilder() + .registerTypeAdapter(LocalDate.class, (JsonSerializer) (date, type, jsonSerializationContext) -> + new JsonPrimitive(date.format(dateFormatter))) + .registerTypeAdapter(LocalDate.class, (JsonDeserializer) (json, type, jsonDeserializationContext) -> + LocalDate.parse(json.getAsString(), dateFormatter)) + .registerTypeAdapter(LocalDateTime.class, (JsonSerializer) (dateTime, type, jsonSerializationContext) -> + new JsonPrimitive(dateTime.format(dateTimeFormatter))) + .registerTypeAdapter(LocalDateTime.class, (JsonDeserializer) (json, type, jsonDeserializationContext) -> + LocalDateTime.parse(json.getAsString(), dateTimeFormatter)) + .create(); + } @Resource diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/plc/PLCServiceImpl.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/plc/PLCServiceImpl.java index 735743c..f4f4fd9 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/plc/PLCServiceImpl.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/plc/PLCServiceImpl.java @@ -96,6 +96,9 @@ public class PLCServiceImpl implements PLCService { public void gyrateCameraByCode(CameraDO cameraDO, String code) { + if (cameraDO == null){ + return; + } CameraIoDO cameraIoDO = cameraIoService.getOne(new QueryWrapper() .eq("camera_Id", cameraDO.getId()) .eq("code", code) @@ -227,20 +230,20 @@ public class PLCServiceImpl implements PLCService { log.info("request json: " + jsonString); // 发起 POST 请求 - ResponseEntity response = restTemplate.exchange( - url, // 替换为你的 API 地址 - HttpMethod.POST, - entity, - String.class - ); +// ResponseEntity response = restTemplate.exchange( +// url, // 替换为你的 API 地址 +// HttpMethod.POST, +// entity, +// String.class +// ); // 输出响应结果 - log.info("Status Code: " + response.getStatusCode()); - log.info("Response Body: " + response.getBody()); - - if (!response.getStatusCode().is2xxSuccessful()) { - setHttpUpdateStatus(url, resultMap, count); - } +// log.info("Status Code: " + response.getStatusCode()); +// log.info("Response Body: " + response.getBody()); +// +// if (!response.getStatusCode().is2xxSuccessful()) { +// setHttpUpdateStatus(url, resultMap, count); +// } } catch (Exception e) { e.printStackTrace(); setHttpUpdateStatus(url, resultMap, count); @@ -315,9 +318,17 @@ public class PLCServiceImpl implements PLCService { } else if (picCmd.startsWith("load")) { needCapture = true; - } else { + } else if (picCmd.equals("C1")){ + needCapture = true; + }else if (picCmd.equals("C2")){ + needCapture = true; return; - + }else if (picCmd.equals("C3")){ + needCapture = true; + direction = order.getToDirection(); + }else if (picCmd.equals("C4")){ + needCapture = true; + direction = order.getToDirection(); } String saveApiPath = dictDataService.parseDictData("base_conf", "data_api_path").getValue(); @@ -343,6 +354,61 @@ public class PLCServiceImpl implements PLCService { //转向原点位 // gyrateCameraByCode(camera, "C5"); } +// +// +// @Override +// public void action( String cmd) { +// OrderDO order = orderMapper.selectOne(new LambdaQueryWrapper() +// .eq(OrderDO::getTaskId, taskId) +// .orderByDesc(OrderDO::getCreateTime) +// .last("limit 1")); +// StreetDO street = streetService.getStreetByPlcId(order.getSrmNumber()); +// if (street == null) { +// log.error("street not found ,plcId :{}", order.getSrmNumber()); +// return; +// } +// //OrderInfo orderInfo = new OrderInfo(street, plcCmdInfo, times, code); +// //判断是否拍照 +// boolean needCapture = false; +// String picCmd = cmd; +// Integer direction = order.getFromDirection(); +// if (picCmd.endsWith("ing")){ +// return; +// } +// if (picCmd.startsWith("unload") ) { +// needCapture = true; +// direction = order.getToDirection(); +// } else if (picCmd.startsWith("load")) { +// needCapture = true; +// +// } else { +// return; +// +// } +// String saveApiPath = dictDataService.parseDictData("base_conf", "data_api_path").getValue(); +// +//// 判断巷道有几个相机 如果只有一个,则调用一个,如果是两个,先判断是否有对拍设置决定那个相机拍摄 +// CameraDO camera = getCameraByLeftRight(street, direction); +//// 调用预置点位 +// gyrateCameraByCode(camera, picCmd); +//// int time = Integer.parseInt(dictDataService.parseDictData("camera_conf", "delay_preset_time").getValue()); +//// try { +//// Thread.sleep(time); +//// } catch (InterruptedException e) { +//// e.printStackTrace(); +//// } +// KsecDataInfo dataInfoData = BeanUtils.toBean(order, KsecDataInfo.class); +// if (needCapture) { +// String pathSrc = PathUtil.createFileName(dataInfoData, street, picCmd, ".jpg"); +// pathSrc = cameraCapture(camera, true, pathSrc, dictDataService.getDictDataList("camera_conf")); +// +// urlResourcesService.save(URLResourcesDo.builder().url(saveApiPath + pathSrc).uuid(order.getUuid()).type("1").little("球机拍照").build()); +// order.setPics(Strings.hasText(order.getPics()) ? order.getPics() + ";" + saveApiPath + pathSrc : saveApiPath + pathSrc); +// orderMapper.updateById(order); +// } +// //转向原点位 +//// gyrateCameraByCode(camera, "C5"); +// } @Resource URLResourcesService urlResourcesService; @@ -356,7 +422,7 @@ public class PLCServiceImpl implements PLCService { @Override public void checkStart(KsecDataInfo dataInfo) { dataInfo.setCountNumber("true"); - dataInfo = dataInfo.toWMSData(); +// dataInfo = dataInfo.toWMSData(); String saveApiPath = dictDataService.parseDictData("base_conf", "data_api_path").getValue(); @@ -395,14 +461,25 @@ public class PLCServiceImpl implements PLCService { } else { - newStock.setStreetId(street.getId()); newStock.setCheckNum(dataInfo.getTaskId()); + newStock.setStreetId(street.getId()); newStock.setDirection(dataInfo.getFromDirection()); newStock.setSide(dataInfo.getFromSide()); newStock.setSeparation(dataInfo.getFromSeparation()); newStock.setRow(dataInfo.getFromRow()); newStock.setColumn(dataInfo.getFromColumn()); + newStock.setTaskId(dataInfo.getTaskId()); + newStock.setWmsCode(dataInfo.getWmsCode()); + newStock.setWmsTrayCode(dataInfo.getWmsTrayCode()); +// newStock.setTrayCode(dataInfo.getTrayCode()); + newStock.setWmsCategory(dataInfo.getWmsCategory()); + newStock.setWmsCount(dataInfo.getWmsCount()); + newStock.setWmsItemCode(dataInfo.getWmsItemCode()); + newStock.setWmsShelfCode(dataInfo.getWmsShelfCode()); + newStock.setWmsPltCode(dataInfo.getWmsPltCode()); + newStock.setWmsCountNumber(dataInfo.getWmsCountNumber()); + newStock.setId(stock.getId()); stock = newStock; stock.setStatus("0"); @@ -446,14 +523,14 @@ public class PLCServiceImpl implements PLCService { public void check(String taskId, String cmd) { //根据扫码配置 获取扫码结果 Map dictDataList = dictDataService.getDictDataList("scan_conf"); - Map csscStartList = dictDataService.getDictDataList("cssc_scan"); + Map csscStartList = dictDataService.getDictDataList("scan_enable"); // 如果存在扫码情况,进行扫码 if (csscStartList.get(cmd) != null) { - String[] scanTypes = csscStartList.get(cmd).getValue().split(";"); + String[] scanTypes = csscStartList.get(cmd).getValue().split(","); StockDO stock = stockService.getOne( new QueryWrapper() .eq("task_id", taskId) - .last("limit 1") + .last("limit 1").orderByDesc("create_time") ); if (stock == null) { log.error("taskId:{}不存在", taskId); @@ -519,7 +596,7 @@ public class PLCServiceImpl implements PLCService { Map dictDataList = dictDataService.getDictDataList("base_conf"); - Map csscStartList = dictDataService.getDictDataList("cssc_scan"); + Map csscStartList = dictDataService.getDictDataList("scan_enable"); StockDO stock = stockService.getOne( new QueryWrapper() .eq("task_id", taskId) @@ -661,11 +738,11 @@ public class PLCServiceImpl implements PLCService { if (street.getCamera1Id() != null) { - String path = zLMediaKitService.startRecord("live", street.getCamera1Id().toString()); + zLMediaKitService.startRecord("live", street.getCamera1Id().toString()); // String path = cameraVideo(street.getCamera1Id(), pathSrc, order.getCreateTime(), endDownLoadTime, dictDataList); } if (street.getCamera2Id() != null) { - String path = zLMediaKitService.startRecord("live", street.getCamera2Id().toString()); + zLMediaKitService.startRecord("live", street.getCamera2Id().toString()); } orderMapper.insert(order); @@ -675,7 +752,6 @@ public class PLCServiceImpl implements PLCService { @Resource ZLMediaKitService zLMediaKitService; - public void orderStop(String taskId) { Map dictDataList = dictDataService.getDictDataList("camera_conf"); LocalDateTime endTime = LocalDateTime.now(); diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/LxService/BoxCountRequest.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/LxService/BoxCountRequest.java new file mode 100644 index 0000000..375fc9c --- /dev/null +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/LxService/BoxCountRequest.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.camera.service.scan.LxService; + +import lombok.Data; + +/** + * 箱子计数请求类 + */ +@Data +public class BoxCountRequest { + private Integer cameraId; + + private String cameraCalibratePath; + + /** + * PCD文件路径 + */ + private String pcdFilePath; + + /** + * 地板高度(mm) + */ + private Double floorHeight; + + /** + * 箱子长度(mm) + */ + private Double boxLength; + + /** + * 箱子宽度(mm) + */ + private Double boxWidth; + + /** + * 箱子高度(mm) + */ + private Double boxHeight; + + /** + * 堆叠类型(如 "1w 2h" 表示1层宽排列,2层高) + * 格式:{数字}w 或 {数字}h,多个用空格分隔 + */ + private String stackType; + + /** + * 每层最大箱子数 + */ + private Integer maxBoxesPerLayer; + + /** + * 初始冗余(mm),默认50mm + */ + private Double baseTolerance; + + /** + * 每层增加的冗余(mm),默认20mm + */ + private Double additionalTolerancePerLevel; + + /** + * 裁剪最小边界 [minX, minY, minZ] + */ + private double[] minBounds; + + /** + * 裁剪最大边界 [maxX, maxY, maxZ] + */ + private double[] maxBounds; + + /** + * 获取默认配置 + */ + public static BoxCountRequest createDefault() { + BoxCountRequest request = new BoxCountRequest(); + request.setFloorHeight(0.0); + request.setBoxLength(200.0); // 默认20cm + request.setBoxWidth(140.0); // 默认14cm + request.setBoxHeight(250.0); // 默认25cm + request.setStackType("1w"); // 默认单层宽排列 + request.setMaxBoxesPerLayer(10); // 默认每层最多10个 + request.setBaseTolerance(50.0); // 默认50mm冗余 + request.setAdditionalTolerancePerLevel(20.0); // 默认每层增加20mm + return request; + } +} diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/LxService/BoxCountResponse.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/LxService/BoxCountResponse.java new file mode 100644 index 0000000..493de28 --- /dev/null +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/LxService/BoxCountResponse.java @@ -0,0 +1,96 @@ +package cn.iocoder.yudao.module.camera.service.scan.LxService; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * 箱子计数响应类 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BoxCountResponse { + + /** + * 是否成功 + */ + private boolean success; + + /** + * 总箱子数 + */ + private int totalBoxCount; + + /** + * 各层箱子数(从1层开始) + */ + private Map boxesPerLayer; + + /** + * 各层有效点数 + */ + private Map pointsPerLayer; + + /** + * 各层面积(mm²) + */ + private Map areaPerLayer; + + /** + * 层数 + */ + private int layerCount; + + /** + * 最高层 + */ + private int maxLayer; + + /** + * 使用的PCD文件路径 + */ + private String pcdFilePath; + + /** + * 计算用时(毫秒) + */ + private long calculationTime; + + /** + * 地板高度 + */ + private double floorHeight; + + /** + * 箱子尺寸 [长, 宽, 高] (mm) + */ + private double[] boxDimensions; + + /** + * 堆叠类型 + */ + private String stackType; + + /** + * 错误消息(仅在失败时返回) + */ + private String errorMessage; + + private String imagePath; + private String result; + + /** + * 创建失败响应 + */ + public static BoxCountResponse error(String errorMessage) { + return BoxCountResponse.builder() + .success(false) + .errorMessage(errorMessage) + .build(); + } +} diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/LxService/LxServiceApi.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/LxService/LxServiceApi.java new file mode 100644 index 0000000..06b2dfc --- /dev/null +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/LxService/LxServiceApi.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.camera.service.scan.LxService; + +import cn.iocoder.yudao.module.camera.dal.dataobject.resources.URLResourcesDo; +import cn.iocoder.yudao.module.camera.dal.dataobject.specificationConf.SpecificationConfDO; +import cn.iocoder.yudao.module.camera.dal.dataobject.stock.StockDO; +import cn.iocoder.yudao.module.camera.dal.dataobject.street.StreetDO; +import cn.iocoder.yudao.module.camera.dal.entity.ScanData; +import cn.iocoder.yudao.module.camera.service.resources.URLResourcesService; +import cn.iocoder.yudao.module.camera.service.specificationConf.SpecificationConfService; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Service +public class LxServiceApi { + @Resource + SpecificationConfService specificationConfService; + + @Resource + RestTemplate restTemplate; + + @Resource + URLResourcesService urlResourcesService; + + BoxCountResponse setHttp(BoxCountRequest request,String url){ + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); +// + BoxCountResponse boxCountResponse = new BoxCountResponse(); + HttpEntity requestHttp = new HttpEntity<>(request, headers); + boxCountResponse = restTemplate.postForObject(url, requestHttp, BoxCountResponse.class); + return boxCountResponse; + } + public ScanData categoryScan(StreetDO streetDO, StockDO stockDO) { + String url = "http://" + streetDO.getPlcIp() + ":" + streetDO.getPlcPort() + "/category/single"; + BoxCountRequest request = new BoxCountRequest(); + request.setCameraId(1); + BoxCountResponse boxCountResponse = setHttp(request,url); + log.info("LxServiceApi categoryScan:{}",boxCountResponse); + ScanData scanData = new ScanData(); + scanData.setCode(boxCountResponse.getResult()); + + urlResourcesService.save(URLResourcesDo.builder().url(boxCountResponse.getImagePath()).uuid( stockDO.getCheckPic()).type("1").little("品规拍照").build()); + return scanData; + } + + public ScanData countScan(StreetDO streetDO, StockDO stockDO) { + + String url = "http://" + streetDO.getPlcIp() + ":" + streetDO.getPlcPort() + "/api/boxCount/calculate"; + SpecificationConfDO specificationConfDO = specificationConfService.getOne(new LambdaQueryWrapper().eq(SpecificationConfDO::getType, stockDO.getWmsItemCode())); + BoxCountRequest request = new BoxCountRequest(); + request.setBoxHeight(Double.valueOf(specificationConfDO.getHeight())); + request.setBoxLength(Double.valueOf(specificationConfDO.getLength())); + request.setBoxWidth(Double.valueOf(specificationConfDO.getWidth())); + request.setMaxBoxesPerLayer(specificationConfDO.getLayersNumber()); + BoxCountResponse boxCountResponse = setHttp(request,url); + log.info("LxServiceApi countScan:{}",boxCountResponse); + ScanData scanData = new ScanData(); + scanData.setCode(boxCountResponse.getResult()); + + urlResourcesService.save(URLResourcesDo.builder().url(boxCountResponse.getImagePath()).uuid( stockDO.getCheckPic()).type("1").little("品规拍照").build()); + return scanData; + } + + +} diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/PCDServiceImpl.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/PCDServiceImpl.java index 09f9e3c..d53fc1d 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/PCDServiceImpl.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/PCDServiceImpl.java @@ -33,4 +33,21 @@ public class PCDServiceImpl implements ScanService{ return restTemplate.postForObject(url, request, ScanData.class); } + + + public ScanData scanPcd(StreetDO streetDO, StockDO stockDO) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); +// + ScanData scanData = new ScanData(); + scanData.setCode(stockDO.getCategory()); + scanData.setIp(streetDO.getPlcIp()); + scanData.setPort(streetDO.getPlcPort()); + scanData.setUuid(stockDO.getCheckPic()); + scanData.setDirection(stockDO.getDirection()); + HttpEntity request = new HttpEntity<>(scanData, headers); + String url = "http://"+streetDO.getPlcIp()+":"+streetDO.getPlcPort()+"/judge/scan"; + + return restTemplate.postForObject(url, request, ScanData.class); + } } diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/ScanServiceFactory.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/ScanServiceFactory.java index 0a1200a..dfafa86 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/ScanServiceFactory.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/ScanServiceFactory.java @@ -4,6 +4,7 @@ import cn.iocoder.yudao.module.camera.dal.dataobject.stock.StockDO; import cn.iocoder.yudao.module.camera.dal.dataobject.street.StreetDO; import cn.iocoder.yudao.module.camera.dal.entity.ScanData; import cn.iocoder.yudao.module.camera.service.Hik3D.HikFlaskApiService; +import cn.iocoder.yudao.module.camera.service.scan.LxService.LxServiceApi; import cn.iocoder.yudao.module.camera.service.scan.yoloCameraApi.YoloServiceImpl; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -27,6 +28,9 @@ public class ScanServiceFactory { @Resource private QRCodeScanServiceImpl qrCodeScanService; + @Resource + LxServiceApi lxServiceApi; + // 返回的ScanData的code会将作为扫描结果,如果和wms的一致,则记录为成功 // 图片保存将在scan方法中实现 @@ -73,14 +77,13 @@ public class ScanServiceFactory { case "9": log.info("yolo识别扫码-检测"); return yoloService.scanDetect(streetDO, stockDO); - // hik3d识别扫码存在 + case "10": - log.info("yolo识别扫码-球机检测"); - return yoloService.scanCameraDetect(streetDO, stockDO); - // 二维码识别 + log.info("lx接口品规"); + return lxServiceApi.categoryScan(streetDO, stockDO);// 二维码识别 case "11": - log.info("二维码识别"); - return qrCodeScanService.scan(streetDO, stockDO); + log.info("lx接口个数"); + return lxServiceApi.countScan(streetDO, stockDO); // 默认情况处理 default: return new ScanData(); diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/yoloCameraApi/YoloServiceImpl.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/yoloCameraApi/YoloServiceImpl.java index b041e25..44ed450 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/yoloCameraApi/YoloServiceImpl.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/scan/yoloCameraApi/YoloServiceImpl.java @@ -8,13 +8,16 @@ import cn.iocoder.yudao.module.camera.service.resources.URLResourcesService; import cn.iocoder.yudao.module.camera.service.scan.ScanService; import cn.iocoder.yudao.module.system.service.dict.DictDataService; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.google.gson.Gson; +import com.google.gson.*; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import okhttp3.*; import org.springframework.stereotype.Service; import java.io.IOException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Objects; @@ -25,7 +28,25 @@ public class YoloServiceImpl implements ScanService { private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); @Resource private OkHttpClient client; - private final Gson gson = new Gson(); + + private final Gson gson; + + public YoloServiceImpl() { + // 配置 Gson 支持 Java 8 时间类型 + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + this.gson = new GsonBuilder() + .registerTypeAdapter(LocalDate.class, (JsonSerializer) (date, type, jsonSerializationContext) -> + new JsonPrimitive(date.format(dateFormatter))) + .registerTypeAdapter(LocalDate.class, (JsonDeserializer) (json, type, jsonDeserializationContext) -> + LocalDate.parse(json.getAsString(), dateFormatter)) + .registerTypeAdapter(LocalDateTime.class, (JsonSerializer) (dateTime, type, jsonSerializationContext) -> + new JsonPrimitive(dateTime.format(dateTimeFormatter))) + .registerTypeAdapter(LocalDateTime.class, (JsonDeserializer) (json, type, jsonDeserializationContext) -> + LocalDateTime.parse(json.getAsString(), dateTimeFormatter)) + .create(); + } @Resource @@ -145,6 +166,37 @@ public class YoloServiceImpl implements ScanService { // return null; } + /** + * 识别是否存在 + * @param streetDO + * @param stockDO + * @return + */ + public ScanData scanningLxApiYolo(StreetDO streetDO, StockDO stockDO) { + ScanData scanData = new ScanData(); + String urlPath ="http://127.0.0.1:8097/judge/scan"; + try { +// scanData.setCode("有货"); + Pojo pojo = new Pojo(); + pojo.setStreetId(streetDO.getPlcId()); + pojo.setDirection(stockDO.getDirection()); + pojo = setHttp(pojo,urlPath+"/yolo/detect"); + + log.info("lx3d拍照结果:"+pojo.toString()); + scanData.setCode(pojo.getType()); + urlResourcesService.save(URLResourcesDo.builder().url(pojo.getPath()).uuid( stockDO.getCheckPic()).type("1").little("有无拍照").build()); + return scanData; + } catch (IOException e) { + log.error("识别异常",e); + System.out.println("识别异常"); + e.printStackTrace(); + throw new RuntimeException(e); + } +// return null; + } + + + /** * 识别是否存在 diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/specificationConf/SpecificationConfService.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/specificationConf/SpecificationConfService.java index 89d0bb4..f626988 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/specificationConf/SpecificationConfService.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/specificationConf/SpecificationConfService.java @@ -4,6 +4,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.camera.controller.admin.specificationConf.vo.SpecificationConfPageReqVO; import cn.iocoder.yudao.module.camera.controller.admin.specificationConf.vo.SpecificationConfSaveReqVO; import cn.iocoder.yudao.module.camera.dal.dataobject.specificationConf.SpecificationConfDO; +import com.baomidou.mybatisplus.extension.service.IService; import jakarta.validation.Valid; /** @@ -11,7 +12,7 @@ import jakarta.validation.Valid; * * @author 管理员 */ -public interface SpecificationConfService { +public interface SpecificationConfService extends IService { /** * 创建品规配置 diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/specificationConf/SpecificationConfServiceImpl.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/specificationConf/SpecificationConfServiceImpl.java index ece4983..8f0aa0a 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/specificationConf/SpecificationConfServiceImpl.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/specificationConf/SpecificationConfServiceImpl.java @@ -9,11 +9,11 @@ import cn.iocoder.yudao.module.camera.dal.dataobject.street.StreetDO; import cn.iocoder.yudao.module.camera.dal.mysql.specificationConf.SpecificationConfMapper; import cn.iocoder.yudao.module.camera.dal.mysql.street.StreetMapper; import cn.iocoder.yudao.module.camera.service.Hik3D.HikFlaskApiService; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -import java.io.IOException; import java.util.List; /** @@ -23,7 +23,7 @@ import java.util.List; */ @Service @Validated -public class SpecificationConfServiceImpl implements SpecificationConfService { +public class SpecificationConfServiceImpl extends ServiceImpl implements SpecificationConfService { @Resource private SpecificationConfMapper specificationConfMapper; @@ -42,13 +42,13 @@ public class SpecificationConfServiceImpl implements SpecificationConfService { SpecificationConfDO specificationConf = BeanUtils.toBean(createReqVO, SpecificationConfDO.class); specificationConfMapper.insert(specificationConf); List streetDOList = streetMapper.selectList(); - for (StreetDO streetDO : streetDOList) { - try { - hikFlaskApiService.addTemplate(specificationConf.getType(), specificationConf,"http://"+streetDO.getPlcIp()+":5000"); - } catch (IOException e) { - throw new RuntimeException(e); - } - } +// for (StreetDO streetDO : streetDOList) { +// try { +// hikFlaskApiService.addTemplate(specificationConf.getType(), specificationConf,"http://"+streetDO.getPlcIp()+":5000"); +// } catch (IOException e) { +// throw new RuntimeException(e); +// } +// } // 返回 return specificationConf.getId(); } @@ -64,13 +64,13 @@ public class SpecificationConfServiceImpl implements SpecificationConfService { SpecificationConfDO specificationConfDO = specificationConfMapper.selectById(updateReqVO.getId()); List streetDOList = streetMapper.selectList(); - for (StreetDO streetDO : streetDOList) { - try { - hikFlaskApiService.addTemplate(specificationConfDO.getType(), specificationConfDO,"http://"+streetDO.getPlcIp()+":5000"); - } catch (IOException e) { - throw new RuntimeException(e); - } - } +// for (StreetDO streetDO : streetDOList) { +// try { +// hikFlaskApiService.addTemplate(specificationConfDO.getType(), specificationConfDO,"http://"+streetDO.getPlcIp()+":5000"); +// } catch (IOException e) { +// throw new RuntimeException(e); +// } +// } } @Override diff --git a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/streamingMedia/ZLMediaKitServiceImpl.java b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/streamingMedia/ZLMediaKitServiceImpl.java index d5a6f57..578b744 100644 --- a/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/streamingMedia/ZLMediaKitServiceImpl.java +++ b/yudao-module-logistics/yudao-module-logistics-biz/src/main/java/cn/iocoder/yudao/module/camera/service/streamingMedia/ZLMediaKitServiceImpl.java @@ -2,9 +2,11 @@ package cn.iocoder.yudao.module.camera.service.streamingMedia; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.module.camera.dal.dataobject.camera.CameraDO; +import cn.iocoder.yudao.module.camera.dal.zlm.Mp4RecordFileResponse; import cn.iocoder.yudao.module.camera.dal.zlm.RtspSessionResponse; import cn.iocoder.yudao.module.camera.service.channel.CameraChannel; import cn.iocoder.yudao.module.camera.util.PathUtil; +import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO; import cn.iocoder.yudao.module.system.service.dict.DictDataService; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -46,42 +48,144 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{ @Resource private DictDataService dictDataService; + /** + * ZLMediaKit 配置信息(内部类,避免每次都从 Redis 获取) + */ + private static class ZlmConfig { + String apiurl; + String secret; + String mp4SavePath; + String mp4SaveApi; + } + + /** + * 获取 ZLMediaKit 配置(一次性获取所有配置,避免多次调用 Redis) + * @return ZLMediaKit 配置对象 + */ + private ZlmConfig getZlmConfig() { + ZlmConfig config = new ZlmConfig(); + try { + Map dictDataMap = dictDataService.getDictDataList("ZLMediaKit_conf"); + if (dictDataMap != null) { + config.apiurl = dictDataMap.get("Apiurl") != null ? dictDataMap.get("Apiurl").getValue() : ""; + config.secret = dictDataMap.get("secret") != null ? dictDataMap.get("secret").getValue() : ""; + config.mp4SavePath = dictDataMap.get("mp4SavePath") != null ? dictDataMap.get("mp4SavePath").getValue() : ""; + config.mp4SaveApi = dictDataMap.get("mp4SaveApi") != null ? dictDataMap.get("mp4SaveApi").getValue() : ""; + } + } catch (Exception e) { + log.error("获取 ZLMediaKit 配置失败: {}", e.getMessage()); + // 返回空配置,后续使用时会检查 + } + return config; + } + @Override public String startRecord(String app, String cameraId) { + ZlmConfig config = getZlmConfig(); - String zlmApiSecret = dictDataService.parseDictData("ZLMediaKit_conf", "secret").getValue(); - - String mp4SavePath = dictDataService.parseDictData("ZLMediaKit_conf", "mp4SavePath").getValue(); Map addParams = new HashMap<>(); - addParams.put("secret", zlmApiSecret); + addParams.put("secret", config.secret); addParams.put("type", "1");//0为hls,1为mp4 addParams.put("vhost", "__defaultVhost__"); addParams.put("app", "live"); - addParams.put("customized_path", mp4SavePath); + addParams.put("customized_path", config.mp4SavePath); addParams.put("stream","camera"+ cameraId); -// addParams.put("url", CameraChannel.getRtspUrl(camera.getIp(), camera.getRtspPort(), camera.getChannel(), camera.getUser(), camera.getPassword(),camera.getType())); -// sendHttp(addParams, "startRecord"); - // 查看是否录像,为录像则重新出发录像 + + // 重试机制:最多尝试6次启动录制 + boolean recordStarted = false; for (int i = 0; i <=5; i++) { RtspSessionResponse rtspSessionResponse = sendHttp(addParams, "startRecord"); if (rtspSessionResponse != null && rtspSessionResponse.isResult()){ + recordStarted = true; break; } + // 如果失败,短暂休眠后重试 + try { + sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("startRecord 等待被中断: camera={}", cameraId); + return ""; + } } - log.info("startRecord camera:"+cameraId); - String path = checkHiddenFilesInDirectory(app,cameraId); - ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); - Runnable task = () -> { - stopRecord(app,cameraId,LocalDateTime.now()); + if (!recordStarted) { + log.error("startRecord 失败,重试6次后仍未成功: camera={}", cameraId); + return ""; + } + + log.info("startRecord 成功: app={}, camera={}", app, cameraId); + + // 延迟1秒后使用API获取当前录制的文件信息 + try { + sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("startRecord 等待被中断: camera={}", cameraId); + return ""; + } + + // 获取当前正在录制的文件路径 + String recordingPath = getCurrentRecordingFile(app, cameraId); + log.info("当前录制文件: app={}, camera={}, path={}", app, cameraId, recordingPath); + // 设置定时器,5分钟后自动停止录制 + ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + Runnable task = () -> { + stopRecord(app, cameraId, LocalDateTime.now()); }; - // 延迟5分钟后执行停止任务,不会将录像文件录5分钟 - long delay =5; - scheduler.schedule(task, delay, TimeUnit.MINUTES); - return path; + // 延迟5分钟后执行停止任务 + scheduler.schedule(task, 5, TimeUnit.MINUTES); + return recordingPath; + } + + /** + * 获取当前正在录制的文件路径 + * @param app 应用名 + * @param cameraId 摄像头ID + * @return 当前录制文件的访问路径,如果没有则返回空字符串 + */ + private String getCurrentRecordingFile(String app, String cameraId) { + ZlmConfig config = getZlmConfig(); + + // 获取当前日期 + LocalDate currentDate = LocalDate.now(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + String currentPeriod = currentDate.format(formatter); + + // 调用 getMp4RecordFile API 获取当前日期的录像文件列表 + Mp4RecordFileResponse response = queryMp4RecordFile(config.apiurl, config.secret, app, cameraId, currentPeriod, config.mp4SavePath); + + if (response != null && response.getCode() == 0 && response.getData() != null + && response.getData().getPaths() != null && !response.getData().getPaths().isEmpty()) { + // 获取最新的文件(列表中第一个就是最新的) + String fileName = response.getData().getPaths().get(0); + + // 检查是否是隐藏文件(以.开头) + if (fileName.startsWith(".")) { + // 隐藏文件(正在录制中),返回完整的访问路径 + return config.mp4SaveApi + "record/" + app + "/camera" + cameraId + "/" + currentPeriod + "/" + fileName; + } else { + // 正常文件(已完成录制) + return config.mp4SaveApi + "record/" + app + "/camera" + cameraId + "/" + currentPeriod + "/" + fileName; + } + } + + // 如果当前日期没有文件,尝试前一天(处理跨天情况) + LocalDate yesterdayDate = currentDate.minusDays(1); + String yesterdayPeriod = yesterdayDate.format(formatter); + response = queryMp4RecordFile(config.apiurl, config.secret, app, cameraId, yesterdayPeriod, config.mp4SavePath); + + if (response != null && response.getCode() == 0 && response.getData() != null + && response.getData().getPaths() != null && !response.getData().getPaths().isEmpty()) { + String fileName = response.getData().getPaths().get(0); + return config.mp4SaveApi + "record/" + app + "/camera" + cameraId + "/" + yesterdayPeriod + "/" + fileName; + } + + log.warn("未找到录制文件: app={}, camera={}", app, cameraId); + return ""; } // 检查指定文件夹中最新的文件名,且文件修改时间在指定时间之后 @@ -128,24 +232,21 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{ } // 检查摄像头目录中最新的文件名,且文件修改时间在指定时间之后 public String getLatestCameraFileNameAfterTime(String app, String cameraId, LocalDateTime afterTime) { - String mp4SavePath = dictDataService.parseDictData("ZLMediaKit_conf", "mp4SavePath").getValue(); + ZlmConfig config = getZlmConfig(); - String mp4SaveApi = dictDataService.parseDictData("ZLMediaKit_conf", "mp4SaveApi").getValue(); // 获取当前日期 LocalDate currentDate = LocalDate.now(); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); String formattedDate = currentDate.format(formatter); - String directoryPath = mp4SavePath + "record/" + app + "/" + cameraId + "/" + formattedDate; - String mp4SaveApiPath = mp4SaveApi + "record/" + app + "/" + cameraId + "/" + formattedDate + "/"; + String directoryPath = config.mp4SavePath + "record/" + app + "/" + cameraId + "/" + formattedDate; + String mp4SaveApiPath = config.mp4SaveApi + "record/" + app + "/" + cameraId + "/" + formattedDate + "/"; return mp4SaveApiPath + getLatestFileNameAfterTime(directoryPath, afterTime); } // 新增方法:检查指定文件夹下是否有以.为开头的文件 public String checkHiddenFilesInDirectory(String app, String cameraId) { + ZlmConfig config = getZlmConfig(); - String mp4SavePath = dictDataService.parseDictData("ZLMediaKit_conf", "mp4SavePath").getValue(); -// mp4SaveApi - String mp4SaveApi = dictDataService.parseDictData("ZLMediaKit_conf", "mp4SaveApi").getValue(); // 获取当前日期 LocalDate currentDate = LocalDate.now(); @@ -155,8 +256,7 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{ // 格式化日期 String formattedDate = currentDate.format(formatter); - String directoryPath = mp4SavePath+"record/"+app+"/camera"+cameraId+"/"+formattedDate; - + String directoryPath = config.mp4SavePath + "record/"+app+"/camera"+cameraId+"/"+formattedDate; Path directory = Paths.get(directoryPath); List hiddenFiles = new ArrayList<>(); @@ -168,7 +268,7 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{ .map(Path::toString) .filter(name -> name.startsWith(".")) .collect(Collectors.toList()); - return hiddenFiles.isEmpty() ? "" : mp4SaveApi+"record/"+app+"/camera"+cameraId+"/"+formattedDate+"/"+removeLeadingDot(hiddenFiles.get(0)); + return hiddenFiles.isEmpty() ? "" : config.mp4SaveApi+"record/"+app+"/camera"+cameraId+"/"+formattedDate+"/"+removeLeadingDot(hiddenFiles.get(0)); } catch (IOException e) { e.printStackTrace(); hiddenFiles= List.of(); // 返回空列表 @@ -178,10 +278,8 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{ // 新增方法:检查指定文件夹下是否有以.为开头的文件 public String checkAndRenameFile(String app, String cameraId,String filePath) { + ZlmConfig config = getZlmConfig(); - String mp4SavePath = dictDataService.parseDictData("ZLMediaKit_conf", "mp4SavePath").getValue(); -// mp4SaveApi - String mp4SaveApi = dictDataService.parseDictData("ZLMediaKit_conf", "mp4SaveApi").getValue(); // 获取当前日期 LocalDate currentDate = LocalDate.now(); @@ -191,11 +289,11 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{ // 格式化日期 String formattedDate = currentDate.format(formatter); // 构建完整路径 - String fullPath = mp4SavePath + "record/" + app + "/camera" + cameraId + "/" + formattedDate + "/." + filePath; - String renamedPath = mp4SavePath + "record/" + app + "/camera" + cameraId + "/" + formattedDate + "/" + filePath; + String fullPath = config.mp4SavePath + "record/" + app + "/camera" + cameraId + "/" + formattedDate + "/." + filePath; + String renamedPath = config.mp4SavePath + "record/" + app + "/camera" + cameraId + "/" + formattedDate + "/" + filePath; // 构建访问路径 - String accessPath = mp4SaveApi + "record/" + app + "/camera" + cameraId + "/" + formattedDate + "/" + filePath; + String accessPath = config.mp4SaveApi + "record/" + app + "/camera" + cameraId + "/" + formattedDate + "/" + filePath; try { Path originalFilePath = Paths.get(fullPath); @@ -220,21 +318,20 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{ * @return 截图文件的访问路径,如果失败返回null */ public void captureSnapshot(CameraDO camera, String savePath) { + ZlmConfig config = getZlmConfig(); PathUtil.checkDirc(savePath); - String zlmApiUrl = dictDataService.parseDictData("ZLMediaKit_conf", "Apiurl").getValue(); - String zlmApiSecret = dictDataService.parseDictData("ZLMediaKit_conf", "secret").getValue(); try { // 构建getSnap请求参数 Map snapParams = new HashMap<>(); - snapParams.put("secret", zlmApiSecret); + snapParams.put("secret", config.secret); snapParams.put("vhost", "__defaultVhost__"); snapParams.put("expire_sec",1); snapParams.put("url",CameraChannel.getRtspUrl(camera.getIp(), camera.getRtspPort(), camera.getChannel(), camera.getUser(), camera.getPassword(),camera.getType())); snapParams.put("timeout_sec", 10); // 设置超时时间 // 构建请求URL - String snapUrl = buildUrl(zlmApiUrl + "getSnap", null, snapParams); + String snapUrl = buildUrl(config.apiurl + "getSnap", null, snapParams); // 发送请求并获取截图 Request request = new Request.Builder() @@ -277,40 +374,113 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{ } @Override public String stopRecord(String app, String stream ,LocalDateTime time) { + ZlmConfig config = getZlmConfig(); - String zlmApiSecret = dictDataService.parseDictData("ZLMediaKit_conf", "secret").getValue(); Map addParams = new HashMap<>(); - addParams.put("secret", zlmApiSecret); + addParams.put("secret", config.secret); addParams.put("type", "1");//0为hls,1为mp4 addParams.put("vhost", "__defaultVhost__"); addParams.put("app", "live"); addParams.put("stream","camera"+ stream); -// addParams.put("url", CameraChannel.getRtspUrl(camera.getIp(), camera.getRtspPort(), camera.getChannel(), camera.getUser(), camera.getPassword(),camera.getType())); + RtspSessionResponse rtspSessionResponse = sendHttp(addParams, "stopRecord"); if (rtspSessionResponse!=null && rtspSessionResponse.isResult()){ try { - sleep(1000); + // 等待文件写入完成 + sleep(2000); } catch (InterruptedException e) { throw new RuntimeException(e); } - String fileName = getLatestCameraFileNameAfterTime(app, "camera"+stream, time.minusSeconds(10)); - return fileName; - }else { + // 使用 ZLMediaKit API 获取最新录像文件 + String recordPath = getLatestMp4FileByApi(app, stream); + return recordPath; + } else { return ""; } } + /** + * 使用 ZLMediaKit 的 getMp4RecordFile API 获取最新的 MP4 录像文件 + * @param app 应用名 + * @param stream 流ID + * @return 录像文件的访问路径,失败返回空字符串 + */ + private String getLatestMp4FileByApi(String app, String stream) { + ZlmConfig config = getZlmConfig(); + + // 获取当前日期和前一天日期(处理跨天情况) + LocalDate currentDate = LocalDate.now(); + LocalDate yesterdayDate = currentDate.minusDays(1); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + // 尝试获取当前日期的录像文件 + String currentPeriod = currentDate.format(formatter); + Mp4RecordFileResponse response = queryMp4RecordFile(config.apiurl, config.secret, app, stream, currentPeriod, config.mp4SavePath); + + if (response != null && response.getCode() == 0 && response.getData() != null + && response.getData().getPaths() != null && !response.getData().getPaths().isEmpty()) { + // 获取最新的文件(列表中第一个就是最新的) + String fileName = response.getData().getPaths().get(0); + return config.mp4SaveApi + "record/" + app + "/camera" + stream + "/" + currentPeriod + "/" + fileName; + } + + // 如果当前日期没有文件,尝试前一天(处理跨天情况) + String yesterdayPeriod = yesterdayDate.format(formatter); + response = queryMp4RecordFile(config.apiurl, config.secret, app, stream, yesterdayPeriod, config.mp4SavePath); + + if (response != null && response.getCode() == 0 && response.getData() != null + && response.getData().getPaths() != null && !response.getData().getPaths().isEmpty()) { + String fileName = response.getData().getPaths().get(0); + return config.mp4SaveApi + "record/" + app + "/camera" + stream + "/" + yesterdayPeriod + "/" + fileName; + } + + log.warn("未找到录像文件: app={}, stream={}", app, stream); + return ""; + } + + /** + * 调用 ZLMediaKit 的 getMp4RecordFile API + * @param zlmApiUrl ZLMediaKit API 地址 + * @param zlmApiSecret API 密钥 + * @param app 应用名 + * @param stream 流ID + * @param period 日期周期(格式:yyyy-MM-dd) + * @param customizedPath 自定义路径 + * @return API 响应 + */ + private Mp4RecordFileResponse queryMp4RecordFile(String zlmApiUrl, String zlmApiSecret, + String app, String stream, String period, String customizedPath) { + try { + Map params = new HashMap<>(); + params.put("secret", zlmApiSecret); + params.put("vhost", "__defaultVhost__"); + params.put("app", app); + params.put("stream", "camera" + stream); + params.put("period", period); + if (customizedPath != null && !customizedPath.isEmpty()) { + params.put("customized_path", customizedPath); + } + + String queryUrl = buildUrl(zlmApiUrl + "getMp4RecordFile", null, params); + String response = get(queryUrl); + Mp4RecordFileResponse recordFileResponse = JsonUtils.parseObject(response, Mp4RecordFileResponse.class); + return recordFileResponse; + } catch (Exception e) { + log.error("查询录像文件失败: period={}, 错误: {}", period, e.getMessage()); + return null; + } + } + @Override public void zlmConf( List list) { + ZlmConfig config = getZlmConfig(); - String zlmApiUrl = dictDataService.parseDictData("ZLMediaKit_conf", "Apiurl").getValue(); - String zlmApiSecret = dictDataService.parseDictData("ZLMediaKit_conf", "secret").getValue(); try { // 查询当前的RTSP拉流代理 Map queryParams = new HashMap<>(); - queryParams.put("secret", zlmApiSecret); - String queryUrl = buildUrl(zlmApiUrl +"getMediaList",null , queryParams); + queryParams.put("secret", config.secret); + String queryUrl = buildUrl(config.apiurl +"getMediaList",null , queryParams); String response = get(queryUrl); RtspSessionResponse rtspSessionResponse = JsonUtils.parseObject(response, RtspSessionResponse.class); // 检查并添加缺失的RTSP代理 @@ -328,7 +498,7 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{ if (!isRtspProxyExists){ try { - addRtspProxy(entry, zlmApiUrl, zlmApiSecret); + addRtspProxy(entry, config.apiurl, config.secret); }catch (Exception e){ e.printStackTrace(); } @@ -368,11 +538,11 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{ } public RtspSessionResponse sendHttp(Map queryParams ,String url){ + ZlmConfig config = getZlmConfig(); - String zlmApiUrl = dictDataService.parseDictData("ZLMediaKit_conf", "Apiurl").getValue(); try { // 查询当前的RTSP拉流代理 - String queryUrl = buildUrl(zlmApiUrl +url,null , queryParams); + String queryUrl = buildUrl(config.apiurl +url,null , queryParams); String response = get(queryUrl); RtspSessionResponse rtspSessionResponse = JsonUtils.parseObject(response, RtspSessionResponse.class); return rtspSessionResponse; diff --git a/yudao-server/src/main/resources/application-dev.yaml b/yudao-server/src/main/resources/application-dev.yaml index 7d4f7de..2cfb5ae 100644 --- a/yudao-server/src/main/resources/application-dev.yaml +++ b/yudao-server/src/main/resources/application-dev.yaml @@ -216,7 +216,7 @@ yudao: order-notify-url: http://yunai.natapp1.cc/admin-api/pay/notify/order # 支付渠道的【支付】回调地址 refund-notify-url: http://yunai.natapp1.cc/admin-api/pay/notify/refund # 支付渠道的【退款】回调地址 access-log: # 访问日志的配置项 - enable: false + enable: true demo: false # 关闭演示模式 tencent-lbs-key: TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E # QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc