import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:fis_common/index.dart'; import 'package:fis_common/logger/logger.dart'; import 'package:fis_i18n/i18n.dart'; import 'package:fis_jsonrpc/rpc.dart'; import 'package:fis_measure/interfaces/process/workspace/application.dart'; import 'package:fis_measure/process/language/measure_language.dart'; import 'package:fis_measure/process/workspace/measure_data_controller.dart'; import 'package:fis_measure/process/workspace/rpc_bridge.dart'; import 'package:fis_measure/utils/prompt_box.dart'; import 'package:fis_measure/values/unit_desc.dart'; import 'package:fis_measure/view/ai_result_modifier/state.dart'; import 'package:fis_measure/view/mobile_view/widgets/throttle.dart' as utils; import 'package:fis_ui/utils/sizer/sizer.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:path_drawing/path_drawing.dart'; import 'package:vid/us/vid_us_image.dart'; import 'package:vid/us/vid_us_unit.dart'; import 'package:http/http.dart' as http; class AiResultModifierController extends GetxController { final rpcHelper = Get.find(); MeasureDataController get measureData => Get.find(); /// 后台语言包控制器 // final languageService = Get.find(); final state = AiResultModifierState(); /// 传入参数 [图像code,图像帧下标,图像元数据, 图像编辑过的code] final String remedicalCode; final int currFrameIndex; final VidUsImage currFrame; /// 初次查询到的完整数据 AIDiagnosisPerImageDTO resultDTO = AIDiagnosisPerImageDTO(); /// 编辑后的完整数据【用于发给后端】 AIDiagnosisPerImageDTO modifiedDataDTO = AIDiagnosisPerImageDTO(); // 用于画布绘制的轮廓点集 List _canvasContoursPoints = []; // 用于画布绘制的关键点集【拖拽模式】 List _canvasKeyPoints = []; // 用于画布绘制的高亮关键点集【拖拽模式】 final List _canvasAffectedKeyPoints = []; // 用于画布绘制的病灶大小横纵比例线段【四个坐标下标】 List _canvasLesionSizePointsIndexes = []; // 用于画布绘制的轮廓关键点下标集合【画轮廓模式】 final List _canvasPenModeKeyPointIndexes = []; // 用于画布绘制的轮廓关键点下标集合【画轮廓模式】 final List _canvasNewContoursPoints = []; // 播放器组件的key final List _aiPoints = []; // 病灶结论列表 List _diagnosisEnumItems = []; // 当前横线像素长度 int _horizontalLengthInPixel = 0; // 当前横线像素长度 int _verticalLengthInPixel = 0; // 当前AI病灶下标 int currentAiDetectedObjectIndex = 0; // 播放器区域的key GlobalKey framePlayerKey = GlobalKey(); // 截图区域的key GlobalKey captureAreaKey = GlobalKey(); // 画布组件的大小 Size aiCanvasSize = Size.zero; // 图像的实际大小 Size frameSize = Size.zero; // 图像的缩放比例 double _scale = 1.0; // 图像的物理单位像素长度 double _unitsPhysicalPixels = 0.0; // 图像的物理单位 String _xUnit = ''; // 当前的轮廓点集 List contours = []; // 当前的病灶大小 AIDiagnosisLesionSize? lesionSize; // 当前的关键点集 List keyPoints = []; // 当前受影响的高亮的关键点下标集合 List affectedKeyPointIndexes = []; // 当前操作模式 AiResultModifierMode _mode = AiResultModifierMode.drag; // 当前是否正在绘制新轮廓 bool _isDrawingNewContours = false; // 拖拽起点 Offset _dragStartPoint = Offset.zero; // 拖拽开始时的轮廓点集【仅用于发请求】 List contoursOnDragStart = []; // 拖拽开始时的关键点集【仅用于发请求】 List keyPointsOnDragStart = []; /// 测量语言包 final measureLanguage = MeasureLanguage(); AiResultModifierController( {required this.remedicalCode, required this.currFrameIndex, required this.currFrame}); late final application = Get.find(); /// 多个ai病灶 List get aiDetectedObjectList => modifiedDataDTO.diagResultsForEachOrgan?.first.detectedObjects ?? []; /// 当前病灶 AIDetectedObject? get aiDetectedObject => modifiedDataDTO .diagResultsForEachOrgan ?.first .detectedObjects?[currentAiDetectedObjectIndex]; List get aiPoints => _aiPoints; List get canvasAffectedKeyPoints => _canvasAffectedKeyPoints; List get canvasContoursPoints => _canvasContoursPoints; List get canvasKeyPoints => _canvasKeyPoints; List get canvasLesionSizePointsIndexes => _canvasLesionSizePointsIndexes; List get canvasNewContoursPoints => _canvasNewContoursPoints; List get canvasPenModeKeyPointIndexes => _canvasPenModeKeyPointIndexes; AiResultModifierMode get currMode => _mode; List get diagnosisEnumItems => _diagnosisEnumItems; /// 当前器官 DiagnosisOrganEnum get diagnosisOrgan => modifiedDataDTO.diagResultsForEachOrgan?.first.organ ?? DiagnosisOrganEnum.Null; /// 获取病灶的水平长度 String get horizontalLength => _countLesionLengthWithUnit(_horizontalLengthInPixel); /// 获取病灶的垂直长度 String get verticalLength => _countLesionLengthWithUnit(_verticalLengthInPixel); /// 病灶横纵比 String get lesionRatio => _verticalLengthInPixel / _horizontalLengthInPixel > 1 || _verticalLengthInPixel / _horizontalLengthInPixel == 1 ? '> 1' : '< 1'; /// 切换操作模式 void changeModifierMode(AiResultModifierMode newMode) { if (_mode == newMode) return; _mode = newMode; _canvasAffectedKeyPoints.clear(); update(['ai_result_modifier', 'ai_mode_change_buttons']); } /// 切换ai病灶 Future changeAiDetectedObjectIndex(int index) async { _setNewCurrContoursToModifiedDataDTO( oldIndex: currentAiDetectedObjectIndex); currentAiDetectedObjectIndex = index; await _updateContoursByIndex(index); update(['ai_result_canvas', 'ai_conclusion_result', 'ai_index_tag']); } /// 切换病灶轮廓 Future _updateContoursByIndex(int index) async { contours = modifiedDataDTO .diagResultsForEachOrgan![0].detectedObjects![index].contours ?? []; List? descriptions = modifiedDataDTO .diagResultsForEachOrgan![0].detectedObjects![index].descriptions; //遍历 descriptions 取出病灶大小 for (AIDiagnosisDescription description in descriptions!) { if (description.type == DiagnosisDescriptionEnum.LesionSize) { lesionSize = AIDiagnosisLesionSize.fromJson(jsonDecode(description.value ?? "")); } } keyPoints = await _queryAllKeyPoints(); _canvasAffectedKeyPoints.clear(); _updateCurrContoursPoints(); _updateCurrKeyPoints(); } /// 更新当前轮廓点集【要在 currentAiDetectedObjectIndex 更新前触发】 void _setNewCurrContoursToModifiedDataDTO({required int oldIndex}) { List newContours = _convertCanvasPoints(_canvasContoursPoints); modifiedDataDTO.diagResultsForEachOrgan![0].detectedObjects![oldIndex] .contours = newContours; List? descriptions = modifiedDataDTO .diagResultsForEachOrgan![0].detectedObjects![oldIndex].descriptions; //遍历 descriptions 更新病灶大小 for (var i = 0; i < descriptions!.length; i++) { if (descriptions[i].type == DiagnosisDescriptionEnum.LesionSize) { descriptions[i].value = jsonEncode(lesionSize); } } modifiedDataDTO.diagResultsForEachOrgan![0].detectedObjects![oldIndex] .descriptions = descriptions; } /// 获取当前的新病灶大小 AIDiagnosisLesionSize _getNewLesionSize(List p1234, int newHorizontalLengthInPixel, int newVerticalLengthInPixel) { return AIDiagnosisLesionSize( horizontalPoint1: p1234[0], horizontalPoint2: p1234[1], verticalPoint1: p1234[2], verticalPoint2: p1234[3], horizontalLengthInPixel: newHorizontalLengthInPixel, verticalLengthInPixel: newVerticalLengthInPixel); } /// 上传当前封面图以及压缩后的缩略图 Future _getCurrImageUrls() async { try { final Uint8List vidImageBytes = currFrame.imageData; /// 生成缩略图 final Rect offscreenCanvasRect = Rect.fromLTWH(0, 0, frameSize.width, frameSize.height); final ui.PictureRecorder recorder = ui.PictureRecorder(); final Canvas offscreenCanvas = Canvas(recorder, offscreenCanvasRect); offscreenCanvas.drawImage( await decodeImageFromList(vidImageBytes), Offset.zero, Paint()); _paintAllContours(offscreenCanvas); final ui.Image orginalFileImage = await recorder .endRecording() .toImage(currFrame.width.toInt(), currFrame.height.toInt()); final orginalFileByteData = await orginalFileImage.toByteData(format: ui.ImageByteFormat.png); final orginalFileByteDataBuffer = orginalFileByteData!.buffer.asUint8List(); final String aiFileToken = await rpcHelper.rpc.storage.uploadUint8List( orginalFileByteDataBuffer, "ai_modified_orginal_${remedicalCode}_$currFrameIndex.png", rpcHelper.userToken) ?? ''; print('coverUrl: $aiFileToken'); /// 生成缩略图 final double scale = _calcScale( srcWidth: currFrame.width.toDouble(), srcHeight: currFrame.height.toDouble(), minWidth: 200, minHeight: 200, ); final int scaledWidth = currFrame.width ~/ scale; final int scaledHeight = currFrame.height ~/ scale; final Rect previewOffscreenCanvasRect = Rect.fromLTWH(0, 0, scaledWidth.toDouble(), scaledHeight.toDouble()); final ui.PictureRecorder lowLevelRecorder = ui.PictureRecorder(); final Canvas previewOffscreenCanvas = Canvas(lowLevelRecorder, previewOffscreenCanvasRect); previewOffscreenCanvas.drawImageRect(orginalFileImage, offscreenCanvasRect, previewOffscreenCanvasRect, Paint()); final ui.Image previewFileImage = await lowLevelRecorder .endRecording() .toImage(scaledWidth, scaledHeight); final previewFileByteData = await previewFileImage.toByteData(format: ui.ImageByteFormat.png); final previewFileByteDataBuffer = previewFileByteData!.buffer.asUint8List(); final String previewFileUrl = await rpcHelper.rpc.storage.uploadUint8List( previewFileByteDataBuffer, "ai_modified_preview_${remedicalCode}_$currFrameIndex.png", rpcHelper.userToken) ?? ''; print('previewFileUrl: $previewFileUrl'); return ImageUrls( aiFileToken: aiFileToken, previewFileUrl: previewFileUrl); } catch (e) { logger.e('get screenshot failed', e); return ImageUrls(aiFileToken: '', previewFileUrl: '', isUploaded: false); } } /// 计算压缩倍率 double _calcScale({ required double srcWidth, required double srcHeight, required double minWidth, required double minHeight, }) { var scaleW = srcWidth / minWidth; var scaleH = srcHeight / minHeight; var scale = max(1.0, min(scaleW, scaleH)); return scale; } /// 在图像上绘制所有轮廓 void _paintAllContours(Canvas canvas) { for (var i = 0; i < modifiedDataDTO.diagResultsForEachOrgan![0].detectedObjects!.length; i++) { contours = modifiedDataDTO .diagResultsForEachOrgan![0].detectedObjects![i].contours ?? []; // 设置虚线圆点画笔 final contoursPaint = Paint() ..color = Colors.green ..strokeCap = StrokeCap.round ..strokeWidth = 3.0 ..style = PaintingStyle.stroke; // 遍历 contoursPoints 绘制轮廓 if (contours.isNotEmpty) { Path path = Path(); path.moveTo(contours[0].x.toDouble(), contours[0].y.toDouble()); for (int i = 1; i < contours.length; i++) { path.lineTo(contours[i].x.toDouble(), contours[i].y.toDouble()); } path.close(); canvas.drawPath( dashPath( path, dashArray: CircularIntervalList([1, 10]), ), contoursPaint); } AIDiagnosisLesionSize currLesionSize = AIDiagnosisLesionSize(); List? descriptions = modifiedDataDTO .diagResultsForEachOrgan![0].detectedObjects![i].descriptions; //遍历 descriptions 取出病灶大小 //Descriptions 为空属于非正常数据 if (descriptions!.isEmpty) { continue; } for (AIDiagnosisDescription description in descriptions) { if (description.type == DiagnosisDescriptionEnum.LesionSize) { currLesionSize = AIDiagnosisLesionSize.fromJson( jsonDecode(description.value ?? "")); } } final AIDiagnosisPoint2D p1 = currLesionSize.horizontalPoint1!; final AIDiagnosisPoint2D p2 = currLesionSize.horizontalPoint2!; final AIDiagnosisPoint2D p3 = currLesionSize.verticalPoint1!; final AIDiagnosisPoint2D p4 = currLesionSize.verticalPoint2!; Path path = Path(); path.moveTo(p1.x.toDouble(), p1.y.toDouble()); path.lineTo(p2.x.toDouble(), p2.y.toDouble()); canvas.drawPath( dashPath( path, dashArray: CircularIntervalList([1, 5]), ), contoursPaint); Path path2 = Path(); path2.moveTo(p3.x.toDouble(), p3.y.toDouble()); path2.lineTo(p4.x.toDouble(), p4.y.toDouble()); canvas.drawPath( dashPath( path2, dashArray: CircularIntervalList([1, 5]), ), contoursPaint); paintX( canvas, Offset(p1.x.toDouble(), p1.y.toDouble()), 6.0, 3, Colors.green, ); paintX( canvas, Offset(p2.x.toDouble(), p2.y.toDouble()), 6.0, 3, Colors.green, ); paintX( canvas, Offset(p3.x.toDouble(), p3.y.toDouble()), 6.0, 3, Colors.green, ); paintX( canvas, Offset(p4.x.toDouble(), p4.y.toDouble()), 6.0, 3, Colors.green, ); } } /// 绘制叉叉 void paintX( Canvas canvas, Offset center, double radius, double width, Color color) { final paint = Paint() ..color = color ..strokeCap = StrokeCap.round ..strokeWidth = width ..style = PaintingStyle.stroke; Path path = Path(); path.moveTo(center.dx - radius, center.dy - radius); path.lineTo(center.dx + radius, center.dy + radius); path.moveTo(center.dx + radius, center.dy - radius); path.lineTo(center.dx - radius, center.dy + radius); canvas.drawPath(path, paint); } /// 获取AI模块的翻译值 String getValuesFromAiLanguage(String code) { final value = measureLanguage.t('ai', code); return value; } /// 重置AI结果 void resetAIResult() async { await _initAIResult(); update(['ai_conclusion_result']); } @override void onClose() { super.onClose(); Sizer.ins.removeListener(_onWindowResize); } @override void onInit() async { super.onInit(); await _getDiagnosisEnumItemsAsync(); _updateModifierInteractiveLayerSize(); _updateImagePhysicalSize(); _initAIResult(); Sizer.ins.addListener(_onWindowResize); } /// 窗口大小改变 void _onWindowResize(_) { update(['ai_result_modifier']); frameSize = Size(currFrame.width.toDouble(), currFrame.height.toDouble()); WidgetsBinding.instance.addPostFrameCallback((_) { final RenderBox box = framePlayerKey.currentContext!.findRenderObject() as RenderBox; final framePlayerSize = Size(box.size.width, box.size.height); _scale = min(framePlayerSize.width / frameSize.width, framePlayerSize.height / frameSize.height); aiCanvasSize = Size(frameSize.width * _scale, frameSize.height * _scale); _updateModifierInteractiveLayerSize(); _updateCurrKeyPoints(); _updateCurrContoursPoints(); _updateCurrAffectedKeyPoints(); // 更新交互层尺寸 update(["ai_result_modifier_interactive_layer", "ai_result_canvas"]); }); } /// 鼠标拖拽 void onMouseDrag(DragUpdateDetails details) { switch (_mode) { case AiResultModifierMode.drag: utils.throttle(() { _onDragModeCallDragFunction(details.localPosition); }, 'onMouseDrag', 100); break; case AiResultModifierMode.pen: _onPenModeCallDragFunction(details.localPosition); break; default: } } /// 鼠标拖拽结束 void onMouseDragEnd(DragEndDetails details) async { switch (_mode) { case AiResultModifierMode.drag: break; case AiResultModifierMode.pen: if (_isDrawingNewContours) { _isDrawingNewContours = false; await _callContourMergeAsync(); _updateCurrContoursPoints(); _updateCurrKeyPoints(); } _canvasNewContoursPoints.clear(); update(['ai_result_canvas']); break; default: } } /// 鼠标拖拽开始【记录起点】 void onMouseDragStart(DragDownDetails details) { switch (_mode) { case AiResultModifierMode.drag: _dragStartPoint = details.localPosition; contoursOnDragStart = contours; keyPointsOnDragStart = keyPoints; break; case AiResultModifierMode.pen: if (_canvasPenModeKeyPointIndexes.isNotEmpty) { _isDrawingNewContours = true; _dragStartPoint = details.localPosition; _canvasNewContoursPoints.clear(); _canvasNewContoursPoints .add(_canvasContoursPoints[_canvasPenModeKeyPointIndexes[0]]); _canvasNewContoursPoints.add(_dragStartPoint); } break; default: } } /// 鼠标离开区域 void onMouseExit(PointerExitEvent e) async { // 延迟200ms (因为鼠标位置更新高亮关键点有100ms延迟) await Future.delayed(const Duration(milliseconds: 200)); _canvasAffectedKeyPoints.clear(); update(['ai_result_canvas']); } /// 鼠标悬浮移动 void onMouseHover(PointerHoverEvent e) async { if (keyPoints.isEmpty) return; switch (_mode) { case AiResultModifierMode.drag: utils.throttle(() { _onDragModeCallHoverFunction(e.localPosition); }, 'onMouseHover', 100); break; case AiResultModifierMode.pen: utils.throttle(() { _onPenModeCallHoverFunction(e.localPosition); }, 'onMouseHover', 10); // Offset point = e.localPosition; break; default: } } @override void onReady() { super.onReady(); _initData(); } /// 保存AI修改结果 Future saveAIResult({ String? code, }) async { PromptBox.toast(i18nBook.realTimeConsultation.uploading.t); _setNewCurrContoursToModifiedDataDTO( oldIndex: currentAiDetectedObjectIndex); try { final ImageUrls imageUrls = await _getCurrImageUrls(); if (!imageUrls.isUploaded) { logger.e("Url not uploaded"); PromptBox.toast(i18nBook.user.saveFailed.t); return; } //该逻辑是为了判断当前是否是编辑状态 //如果AIEditCode为空,说明当前模式为从源Vid里新建单帧Vid进行AI信息的update操作 //否则,则是在一个编辑过的Vid上面进行AI信息的update操作 bool isAIEditMode = measureData .measureInfoData.remedicalAISelectedInfoCode.isNotNullOrEmpty; final result = await rpcHelper.rpc.remedical.saveRemedicalAISelectedInfoAsync( SaveRemedicalAISelectedInfoRequest( token: rpcHelper.userToken, remedicalCode: remedicalCode, code: isAIEditMode ? measureData.measureInfoData.remedicalAISelectedInfoCode : null, frameIndex: currFrameIndex, // diagnosisConclusion: diagnosisOrgan, previewFileToken: imageUrls.previewFileUrl, aIFileToken: imageUrls.aiFileToken, diagnosisData: jsonEncode(modifiedDataDTO), ), ); if (result) { PromptBox.toast( "${i18nBook.user.saveSuccess.t} \r\n ${i18nBook.measure.saveLocation.t + ' > ' + i18nBook.measure.measureImage.t}"); Get.back(); } else { logger.e("Server result is false"); PromptBox.toast(i18nBook.user.saveFailed.t); } } catch (e) { logger.e("Operation failed with exception ${e} "); PromptBox.toast(i18nBook.user.saveFailed.t); } } /// 加载AI结果并调用绘制 Future _initAIResult() async { try { var existAIResult = jsonDecode(measureData.aiResults); //当aiResult==1时,为具有单帧AI结果图像,无论是编辑过的还是原vid的单帧图像 //均可用此从measureData里读取,不受影响,因此该处无需区分图像源是AI编辑还是普通VID if (measureData .measureInfoData.remedicalAISelectedInfoCode.isNotNullOrEmpty && existAIResult.length == 1) { resultDTO = AIDiagnosisPerImageDTO.fromJson(existAIResult[0]); } else { final result = await rpcHelper.rpc.remedical.getRemedicalDiagnosisDataAsync( GetRemedicalDiagnosisDataRequest( token: rpcHelper.userToken, remedicalCode: remedicalCode, frameIndex: currFrameIndex, ), ); resultDTO = AIDiagnosisPerImageDTO.fromJson(jsonDecode(result)); } List legalObjs = []; var tempResultDto = resultDTO; var rawObjs = tempResultDto.diagResultsForEachOrgan![0].detectedObjects!; for (var detectedObject in rawObjs) { var isLegalObject = detectedObject.descriptions?.isNotEmpty ?? false; if (isLegalObject) { legalObjs.add(detectedObject); } } rawObjs.clear(); rawObjs.addAll(legalObjs); modifiedDataDTO = tempResultDto; contours = resultDTO.diagResultsForEachOrgan![0] .detectedObjects![currentAiDetectedObjectIndex].contours ?? []; List? descriptions = resultDTO .diagResultsForEachOrgan![0] .detectedObjects![currentAiDetectedObjectIndex] .descriptions; //遍历 descriptions 取出病灶大小 for (AIDiagnosisDescription description in descriptions!) { if (description.type == DiagnosisDescriptionEnum.LesionSize) { lesionSize = AIDiagnosisLesionSize.fromJson( jsonDecode(description.value ?? "")); } } keyPoints = await _queryAllKeyPoints(); _canvasAffectedKeyPoints.clear(); _updateCurrContoursPoints(); _updateCurrKeyPoints(); update(['ai_result_canvas', 'ai_result_panel', 'ai_index_tag']); } catch (e) { logger.e('load ai result failed', e); } } /// 更新交互层尺寸 void _updateModifierInteractiveLayerSize() { frameSize = Size(currFrame.width.toDouble(), currFrame.height.toDouble()); WidgetsBinding.instance.addPostFrameCallback((_) { final RenderBox box = framePlayerKey.currentContext!.findRenderObject() as RenderBox; final framePlayerSize = Size(box.size.width, box.size.height); _scale = min(framePlayerSize.width / frameSize.width, framePlayerSize.height / frameSize.height); aiCanvasSize = Size(frameSize.width * _scale, frameSize.height * _scale); /// 更新交互层尺寸 update(["ai_result_modifier_interactive_layer"]); }); } /// 计算带单位的病灶长度 String _countLesionLengthWithUnit(int length) { String lengthStr = (length * _unitsPhysicalPixels).toStringAsFixed(2).toString(); return "$lengthStr $_xUnit"; } /// 更新图像物理尺度信息 void _updateImagePhysicalSize() { _unitsPhysicalPixels = (application.visuals[0].visualAreas[0].viewport?.region.width)! / (application.frameData!.width).toDouble(); VidUsUnit targetUnit = application.visuals[0].visualAreas[0].viewport?.xUnit ?? VidUsUnit.cm; _xUnit = UnitDescriptionMap.getDesc(targetUnit); } /// 自动吸附闭合判断 void _autoCloseContours() async { if (_canvasNewContoursPoints.length < 6) return; double minDistance = double.infinity; int nearestKeyPointIndex = -1; final lastPoint = _canvasNewContoursPoints.last; for (int i = 0; i < canvasContoursPoints.length; i++) { final point = canvasContoursPoints[i]; final double distance = (point - lastPoint).distance; if (distance < minDistance) { minDistance = distance; nearestKeyPointIndex = i; } } if (minDistance < 6) { _canvasPenModeKeyPointIndexes.add(nearestKeyPointIndex); _canvasNewContoursPoints.add(canvasContoursPoints[nearestKeyPointIndex]); _isDrawingNewContours = false; await _callContourMergeAsync(); _updateCurrContoursPoints(); _updateCurrKeyPoints(); } } /// 发送请求通知后端合并轮廓 Future _callContourMergeAsync() async { final ContourMergeResult result = await rpcHelper.rpc.aIDiagnosis.contourMergeAsync( ContourMergeRequest( token: rpcHelper.userToken, contourPoints: contours, lesionSize: lesionSize, drawingNewContourPoints: _convertCanvasPoints(_canvasNewContoursPoints), ), ); contours = result.dstContours ?? []; lesionSize = result.dstLesionSize; keyPoints = await _queryAllKeyPoints(); return true; } /// 画布坐标系转换【画布坐标系 -> 接口坐标系】 List _convertCanvasPoints(List points) { List result = []; for (Offset point in points) { result.add( AIDiagnosisPoint2D(x: point.dx ~/ _scale, y: point.dy ~/ _scale)); } return result; } /// 关键点坐标转换【接口坐标系 -> 画布坐标系】同时更新横纵比例线段下标 List _convertKeyPoints(List points) { List result = []; List pointIndexes = List.generate(4, (_) => 0); for (int i = 0; i < points.length; i++) { final point = points[i]; if (point.point == null) continue; result.add(Offset(point.point!.x.toDouble() * _scale, point.point!.y.toDouble() * _scale)); if (point.type != DiagnosisKeyPointType.OtherKeyPoints) { switch (point.type) { case DiagnosisKeyPointType.HorizontalPointLeft: pointIndexes[0] = i; break; case DiagnosisKeyPointType.HorizontalPointRight: pointIndexes[1] = i; break; case DiagnosisKeyPointType.VerticalPointUp: pointIndexes[2] = i; break; case DiagnosisKeyPointType.VerticalPointDown: pointIndexes[3] = i; break; default: } } } _canvasLesionSizePointsIndexes = pointIndexes; _updateLesionSizeAndRatio(); return result; } /// 坐标转换【接口坐标系 -> 画布坐标系】 List _convertPoints(List points) { List result = []; for (AIDiagnosisPoint2D point in points) { result.add( Offset(point.x.toDouble() * _scale, point.y.toDouble() * _scale)); } return result; } /// 获取ai结果相关枚举集合 Future _getDiagnosisEnumItemsAsync() async { final getDiagnosisEnumItems = await rpcHelper.rpc.aIDiagnosis.getDiagnosisEnumItemsAsync( GetDiagnosisEnumItemsRequest( token: rpcHelper.userToken, ), ); _diagnosisEnumItems = getDiagnosisEnumItems.source ?? []; } void _initData() { update(["ai_result_modifier"]); } /// 在拖拽模式下触发拖拽事件【每隔100ms触发一次】 void _onDragModeCallDragFunction(Offset pos) async { AIDiagnosisPoint2D startPoint = AIDiagnosisPoint2D( x: _dragStartPoint.dx ~/ _scale, y: _dragStartPoint.dy ~/ _scale); AIDiagnosisPoint2D endPoint = AIDiagnosisPoint2D(x: pos.dx ~/ _scale, y: pos.dy ~/ _scale); final bool success = await _queryDragResult(startPoint, endPoint); if (success) { _updateCurrKeyPoints(); _updateCurrContoursPoints(); _updateCurrAffectedKeyPoints(); update(["ai_result_canvas"]); } } /// 在拖拽模式下,通过鼠标位置更新高亮的关键点下标【每隔100ms触发一次】 void _onDragModeCallHoverFunction(Offset localPosition) async { final mousePos = AIDiagnosisPoint2D( x: localPosition.dx ~/ _scale, y: localPosition.dy ~/ _scale); affectedKeyPointIndexes = await _queryAffectedKeyPoints(mousePos); _updateCurrAffectedKeyPoints(); update(["ai_result_canvas"]); } /// 在画轮廓模式下触发拖拽事件 void _onPenModeCallDragFunction(Offset pos) async { if (!_isDrawingNewContours) return; // 点间距【疏密程度】 const double pointDistance = 8; final double distance = (pos - _canvasNewContoursPoints.last).distance; if (distance >= pointDistance) { int numPointsToInsert = (distance / pointDistance).ceil() - 1; // 需要插入的点数 for (int i = 0; i < numPointsToInsert; i++) { double t = (i + 1) / (numPointsToInsert + 1); Offset interpolatedPoint = Offset( _canvasNewContoursPoints.last.dx + t * (pos.dx - _canvasNewContoursPoints.last.dx), _canvasNewContoursPoints.last.dy + t * (pos.dy - _canvasNewContoursPoints.last.dy), ); _canvasNewContoursPoints.add(interpolatedPoint); } _canvasNewContoursPoints.add(pos); update(["ai_result_canvas"]); } _autoCloseContours(); } /// 在画轮廓模式下,通过鼠标位置更新最近的关键点【每隔10ms触发一次】 void _onPenModeCallHoverFunction(Offset localPosition) async { double minDistance = double.infinity; int nearestKeyPointIndex = -1; for (int i = 0; i < canvasContoursPoints.length; i++) { final point = canvasContoursPoints[i]; final double distance = (point - localPosition).distance; if (distance < minDistance) { minDistance = distance; nearestKeyPointIndex = i; } } _canvasPenModeKeyPointIndexes.clear(); if (minDistance < 10) { _canvasPenModeKeyPointIndexes.add(nearestKeyPointIndex); } update(["ai_result_canvas"]); } /// 根据鼠标位置查询受影响的关键点 Future> _queryAffectedKeyPoints(AIDiagnosisPoint2D mousePos) async { try { final List result = await rpcHelper.rpc.aIDiagnosis.affectedKeyPointsByDragActionAsync( AffectedKeyPointsByDragActionRequest( token: rpcHelper.userToken, keyPoints: keyPoints, mousePoint: mousePos, ), ); // print(result); return result; } catch (e) { return []; } } /// 查询所有关键点【需要先存好contours和lesionSize】 Future> _queryAllKeyPoints() async { try { final List result = await rpcHelper.rpc.aIDiagnosis.getKeyPointsOfContourAsync( GetKeyPointsOfContourRequest( token: rpcHelper.userToken, contours: contours, lesionSize: lesionSize, ), ); return result; } catch (e) { return []; } } /// 查询拖拽结果集合【需要先存好 contoursOnDragStart 和 keyPointsOnDragStart】 Future _queryDragResult( AIDiagnosisPoint2D startPoint, AIDiagnosisPoint2D endPoint) async { try { final ContourAndKeyPointsAfterDragResult result = await rpcHelper.rpc.aIDiagnosis.contourAndKeyPointsAfterDragAsync( ContourAndKeyPointsAfterDragRequest( token: rpcHelper.userToken, contours: contoursOnDragStart, keyPoints: keyPointsOnDragStart, startPoint: startPoint, endPoint: endPoint, ), ); keyPoints = result.dstKeyPoints ?? []; contours = result.dstContours ?? []; affectedKeyPointIndexes = result.affectedKeyPointIndexes!; return true; } catch (e) { return false; } } // 根据病灶四个点位置横纵比参数 void _updateLesionSizeAndRatio() { if (_canvasLesionSizePointsIndexes.length != 4) return; if (keyPoints.length < 4) return; final pIndexs = _canvasLesionSizePointsIndexes; final p1 = keyPoints[pIndexs[0]].point!; final p2 = keyPoints[pIndexs[1]].point!; final p3 = keyPoints[pIndexs[2]].point!; final p4 = keyPoints[pIndexs[3]].point!; /// 计算 p1 到 p2 的像素距离 更新到 _horizontalLengthInPixel /// 计算 p3 到 p4 的像素距离 更新到 _verticalLengthInPixel double _horizontalLength = (Offset(p1.x.toInt().toDouble(), p1.y.toInt().toDouble()) - Offset(p2.x.toInt().toDouble(), p2.y.toInt().toDouble())) .distance; _horizontalLengthInPixel = _horizontalLength.ceil(); double _verticalLength = (Offset(p3.x.toInt().toDouble(), p3.y.toInt().toDouble()) - Offset(p4.x.toInt().toDouble(), p4.y.toInt().toDouble())) .distance; _verticalLengthInPixel = _verticalLength.ceil(); lesionSize = _getNewLesionSize( [p1, p2, p3, p4], _horizontalLengthInPixel, _verticalLengthInPixel); update(['ai_result_lesion_size', 'ai_result_lesion_ratio']); } /// [⭐ _canvasAffectedKeyPoints ] 根据当前的受影响关键点下标更新受影响关键点集 void _updateCurrAffectedKeyPoints() { _canvasAffectedKeyPoints.clear(); if (keyPoints.isEmpty) return; for (int i = 0; i < keyPoints.length; i++) { if (affectedKeyPointIndexes.contains(i)) { _canvasAffectedKeyPoints.add(Offset( keyPoints[i].point!.x.toDouble() * _scale, keyPoints[i].point!.y.toDouble() * _scale)); } } } /// [⭐ _canvasContoursPoints ] 更新当前轮廓点 void _updateCurrContoursPoints() { _canvasContoursPoints = _convertPoints(contours); } /// [⭐ _canvasKeyPoints ] 更新当前关键点 void _updateCurrKeyPoints() async { _canvasKeyPoints = _convertKeyPoints(keyPoints); } } enum AiResultModifierMode { /// 拖拽 drag, /// 画笔 pen, /// 截图 screenshot, } ///存储服务扩展类 extension StorageServiceExt on StorageService { ///鉴权 fileName 为空则接口报错,所以此处设置一个默认值 Future getAuth({ String? fileName, bool? isRechristen, List? urlParams, List? headerParams, String? requestMethod, required String userToken, }) async { try { final result = await getAuthorizationAsync(FileServiceRequest( token: userToken, fileName: fileName ?? "dat", isRechristen: isRechristen ?? true, urlParams: urlParams, headerParams: headerParams, requestMethod: requestMethod, )); return result; } catch (e) { return StorageServiceSettingDTO(); } } ///文件上传(UInt8List) Future uploadUint8List(Uint8List buffer, String name, String token, [bool? isRechristen]) async { try { var nameInfos = name.split('.'); final auth = await getAuth( fileName: nameInfos.last, isRechristen: isRechristen, userToken: token, ); Map params = {}; params['Authorization'] = auth.authorization!; params['ContentType'] = auth.contentType!; final response = await http .put( Uri.parse(auth.storageUrl!), body: buffer, headers: params, ) .timeout( const Duration(seconds: 30), ); if (response.statusCode == 200) { return auth.storageUrl; } } catch (e) { logger.e('StorageServiceExt uploadUint8List ex:$e'); } return null; } } class ImageUrls { /// 原始图像地址 String aiFileToken; /// 缩略图地址 String previewFileUrl; /// 是否已经上传 bool isUploaded = true; ImageUrls({ required this.aiFileToken, required this.previewFileUrl, this.isUploaded = true, }); }