Эх сурвалжийг харах

update(ai): AI 编辑移入测量库

gavin.chen 1 жил өмнө
parent
commit
19623fc1a8

+ 59 - 0
lib/define.dart

@@ -0,0 +1,59 @@
+import 'package:fis_ui/define.dart';
+import 'package:flutter/widgets.dart';
+import 'package:get/get.dart';
+
+/// for temporary preview only!!!
+class QuickFWidget extends StatelessWidget implements FWidget {
+  const QuickFWidget(this.child, {Key? key}) : super(key: key);
+  final Widget child;
+  @override
+  Widget build(BuildContext context) => child;
+}
+
+typedef FWidgetCallback = FWidget Function();
+
+typedef AsyncVoidCallback = Future<void> Function();
+
+class FObx extends Obx implements FWidget {
+  FObx(FWidgetCallback builder) : super(builder);
+}
+
+abstract class FisView<T> extends GetView<T> implements FWidget {
+  const FisView({Key? key}) : super(key: key);
+  @override
+  FWidget build(BuildContext context);
+}
+
+class FisBuilder<T extends GetxController> extends GetBuilder<T>
+    implements FWidget {
+  const FisBuilder({
+    Key? key,
+    T? init,
+    bool global = true,
+    required GetControllerBuilder<T> builder,
+    bool autoRemove = true,
+    bool assignId = false,
+    Object Function(T value)? filter,
+    String? tag,
+    void Function(GetBuilderState<T> state)? initState,
+    dispose,
+    didChangeDependencies,
+    Object? id,
+    void Function(GetBuilder oldWidget, GetBuilderState<T> state)?
+        didUpdateWidget,
+  }) : super(
+          key: key,
+          init: init,
+          global: global,
+          builder: builder,
+          autoRemove: autoRemove,
+          assignId: assignId,
+          initState: initState,
+          filter: filter,
+          tag: tag,
+          dispose: dispose,
+          id: id,
+          didChangeDependencies: didChangeDependencies,
+          didUpdateWidget: didUpdateWidget,
+        );
+}

+ 17 - 0
lib/process/workspace/rpc_helper.dart

@@ -0,0 +1,17 @@
+import 'package:fis_jsonrpc/rpc.dart';
+
+/// 需要在主项目先 Put
+class RPCHelper {
+  final JsonRpcProxy _rpc;
+  String _userToken = "";
+  RPCHelper(this._rpc, this._userToken);
+
+  JsonRpcProxy get rpc => _rpc;
+
+  String get userToken => _userToken;
+
+  /// TODO: 如果在第二窗口没有 token 需要手动设置一次
+  setToken(String token) {
+    _userToken = token;
+  }
+}

+ 584 - 0
lib/view/ai_result_modifier/controller.dart

@@ -0,0 +1,584 @@
+import 'dart:convert';
+import 'dart:math';
+
+import 'package:fis_jsonrpc/rpc.dart';
+import 'package:fis_measure/process/language/measure_language.dart';
+import 'package:fis_measure/process/workspace/rpc_helper.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:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class AiResultModifierController extends GetxController {
+  final rpcHelper = Get.find<RPCHelper>();
+
+  /// 后台语言包控制器
+  // final languageService = Get.find<LanguageService>();
+  final state = AiResultModifierState();
+
+  /// 传入的图像参数
+  // String patientCode = "";
+  String remedicalCode = "";
+  // String recordCode = "";
+
+  /// 初次查询到的完整数据
+  AIDiagnosisPerImageDTO resultDTO = AIDiagnosisPerImageDTO();
+
+  /// 编辑后的完整数据【用于发给后端】
+  AIDiagnosisPerImageDTO modifiedDataDTO = AIDiagnosisPerImageDTO();
+
+  // 用于画布绘制的轮廓点集
+  List<Offset> _canvasContoursPoints = [];
+  // 用于画布绘制的关键点集【拖拽模式】
+  List<Offset> _canvasKeyPoints = [];
+  // 用于画布绘制的高亮关键点集【拖拽模式】
+  final List<Offset> _canvasAffectedKeyPoints = [];
+  // 用于画布绘制的病灶大小横纵比例线段【四个坐标下标】
+  List<int> _canvasLesionSizePointsIndexes = [];
+  // 用于画布绘制的轮廓关键点下标集合【画轮廓模式】
+  final List<int> _canvasPenModeKeyPointIndexes = [];
+  // 用于画布绘制的轮廓关键点下标集合【画轮廓模式】
+  final List<Offset> _canvasNewContoursPoints = [];
+
+  // 播放器组件的key
+  final List<Offset> _aiPoints = [];
+  // 病灶结论列表
+  List<EnumItemDTO> _diagnosisEnumItems = [];
+  // 当前横线像素长度
+  final int _horizontalLengthInPixel = 0;
+  // 当前横线像素长度
+  final int _verticalLengthInPixel = 0;
+
+  GlobalKey framePlayerKey = GlobalKey();
+  // 画布组件的大小
+  Size aiCanvasSize = Size.zero;
+  // 图像的实际大小
+  Size frameSize = Size.zero;
+  // 图像的缩放比例
+  double _scale = 1.0;
+
+  // 当前的轮廓点集
+  List<AIDiagnosisPoint2D> contours = [];
+  // 当前的病灶大小
+  AIDiagnosisLesionSize? lesionSize;
+
+  // 当前的关键点集
+  List<DiagnosisKeyPointDTO> keyPoints = [];
+  // 当前受影响的高亮的关键点下标集合
+  List<int> affectedKeyPointIndexes = [];
+  // 当前操作模式
+  AiResultModifierMode _mode = AiResultModifierMode.drag;
+  // 当前是否正在绘制新轮廓
+  bool _isDrawingNewContours = false;
+  // 拖拽起点
+  Offset _dragStartPoint = Offset.zero;
+  // 拖拽开始时的轮廓点集【仅用于发请求】
+  List<AIDiagnosisPoint2D> contoursOnDragStart = [];
+  // 拖拽开始时的关键点集【仅用于发请求】
+  List<DiagnosisKeyPointDTO> keyPointsOnDragStart = [];
+
+  /// 测量语言包
+  final measureLanguage = MeasureLanguage();
+
+  AiResultModifierController();
+
+  /// 多个ai病灶
+  List<AIDetectedObject> get aiDetectedObjectList =>
+      modifiedDataDTO.diagResultsForEachOrgan?.first.detectedObjects ?? [];
+
+  List<Offset> get aiPoints => _aiPoints;
+
+  List<Offset> get canvasAffectedKeyPoints => _canvasAffectedKeyPoints;
+  List<Offset> get canvasContoursPoints => _canvasContoursPoints;
+  List<Offset> get canvasKeyPoints => _canvasKeyPoints;
+
+  List<int> get canvasLesionSizePointsIndexes => _canvasLesionSizePointsIndexes;
+  List<Offset> get canvasNewContoursPoints => _canvasNewContoursPoints;
+  List<int> get canvasPenModeKeyPointIndexes => _canvasPenModeKeyPointIndexes;
+  AiResultModifierMode get currMode => _mode;
+
+  List<EnumItemDTO> get diagnosisEnumItems => _diagnosisEnumItems;
+
+  /// 当前器官
+  DiagnosisOrganEnum get diagnosisOrgan =>
+      modifiedDataDTO.diagResultsForEachOrgan?.first.organ ??
+      DiagnosisOrganEnum.Null;
+  int get horizontalLengthInPixel => _horizontalLengthInPixel;
+  double get scale => _scale;
+
+  int get verticalLengthInPixel => _verticalLengthInPixel;
+
+  /// 切换操作模式
+  void changeModifierMode(AiResultModifierMode newMode) {
+    if (_mode == newMode) return;
+    _mode = newMode;
+    _canvasAffectedKeyPoints.clear();
+    update(['ai_result_modifier']);
+  }
+
+  /// 获取AI模块的翻译值
+  String getValuesFromAiLanguage(String code) {
+    final value = measureLanguage.t('ai', code);
+    return value;
+  }
+
+  /// 加载AI结果并调用绘制
+  Future<void> loadAIResult() async {
+    try {
+      final result =
+          await rpcHelper.rpc.remedical.getRemedicalDiagnosisDataAsync(
+        GetRemedicalDiagnosisDataRequest(
+          token: rpcHelper.userToken,
+          remedicalCode: remedicalCode,
+          frameIndex: 0,
+        ),
+      );
+      resultDTO = AIDiagnosisPerImageDTO.fromJson(jsonDecode(result));
+      modifiedDataDTO = AIDiagnosisPerImageDTO.fromJson(jsonDecode(result));
+      contours =
+          resultDTO.diagResultsForEachOrgan![0].detectedObjects![0].contours ??
+              [];
+      List<AIDiagnosisDescription>? descriptions = resultDTO
+          .diagResultsForEachOrgan![0].detectedObjects![0].descriptions;
+      //遍历 descriptions 取出病灶大小
+      for (AIDiagnosisDescription description in descriptions!) {
+        if (description.type == DiagnosisDescriptionEnum.LesionSize) {
+          lesionSize = AIDiagnosisLesionSize.fromJson(
+              jsonDecode(description.value ?? ""));
+        }
+      }
+      keyPoints = await _queryAllKeyPoints();
+      _updateCurrContoursPoints();
+      _updateCurrKeyPoints();
+      _getDiagnosisEnumItemsAsync();
+      update(['ai_result_canvas', 'ai_result_panel']);
+    } catch (e) {
+      print(e);
+    }
+  }
+
+  @override
+  void onClose() {
+    super.onClose();
+    print("AiResultModifierController close");
+  }
+
+  /// 图像尺寸加载完成的回调
+  void onFrameDataLoaded(Size _frameSize) {
+    print("图像尺寸 $_frameSize");
+    frameSize = _frameSize;
+    final RenderBox box =
+        framePlayerKey.currentContext!.findRenderObject() as RenderBox;
+    final framePlayerSize = Size(box.size.width, box.size.height);
+    print("容器尺寸 $framePlayerSize");
+    _scale = min(framePlayerSize.width / frameSize.width,
+        framePlayerSize.height / frameSize.height);
+    aiCanvasSize = Size(frameSize.width * _scale, frameSize.height * _scale);
+    print("缩放比 $_scale");
+
+    /// 更新交互层尺寸
+    update(["ai_result_modifier_interactive_layer"]);
+  }
+
+  @override
+  void onInit() {
+    super.onInit();
+    print("AiResultModifierController init");
+    // 获取传递的参数
+    final Map<String, String> args = Get.arguments;
+    remedicalCode = args["remedicalCode"] ?? "";
+    print(args);
+  }
+
+  /// 鼠标拖拽
+  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 onMouseHover(PointerHoverEvent e) async {
+    if (keyPoints.isEmpty) return;
+    switch (_mode) {
+      case AiResultModifierMode.drag:
+        utils.throttle(() {
+          _onDragModeCallHoverFunction(e.localPosition);
+        }, 'onMouseHover', 100);
+        break;
+      case AiResultModifierMode.pen:
+        print("画笔模式,遍历查找最近的点");
+        utils.throttle(() {
+          _onPenModeCallHoverFunction(e.localPosition);
+        }, 'onMouseHover', 10);
+        // Offset point = e.localPosition;
+        break;
+      default:
+    }
+  }
+
+  @override
+  void onReady() {
+    super.onReady();
+    _initData();
+  }
+
+  /// 保存AI修改结果
+  Future<void> saveAIResult({
+    String? code,
+    int frameIndex = 0,
+  }) async {
+    try {
+      final result =
+          await rpcHelper.rpc.remedical.saveRemedicalAISelectedInfoAsync(
+        SaveRemedicalAISelectedInfoRequest(
+          token: rpcHelper.userToken,
+          remedicalCode: remedicalCode,
+          code: code,
+          frameIndex: frameIndex,
+          diagnosisData: jsonEncode(resultDTO),
+        ),
+      );
+    } catch (e) {
+      print(e);
+    }
+  }
+
+  /// 自动吸附闭合判断
+  void _autoCloseContours() async {
+    if (_canvasNewContoursPoints.length < 6) return;
+    double minDistance = double.infinity;
+    int nearestKeyPointIndex = -1;
+    final lastPoint = _canvasNewContoursPoints.last;
+
+    /// 遍历所有关键点keyPoints,找到离localPosition最近的关键点
+    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;
+      }
+    }
+    print("最小距离 $minDistance");
+    if (minDistance < 6) {
+      print("吸附成功");
+      _canvasPenModeKeyPointIndexes.add(nearestKeyPointIndex);
+      _canvasNewContoursPoints.add(canvasContoursPoints[nearestKeyPointIndex]);
+      _isDrawingNewContours = false;
+      await _callContourMergeAsync();
+      _updateCurrContoursPoints();
+      _updateCurrKeyPoints();
+    }
+  }
+
+  /// 发送请求通知后端合并轮廓
+  Future<bool> _callContourMergeAsync() async {
+    final ContourMergeResult result =
+        await rpcHelper.rpc.aIDiagnosis.contourMergeAsync(
+      ContourMergeRequest(
+        token: rpcHelper.userToken,
+        contourPoints: contours,
+        lesionSize: lesionSize,
+        drawingNewContourPoints: _convertCanvasPoints(_canvasNewContoursPoints),
+      ),
+    );
+    //TODO:此处可以拿到合并后的纵横比数据 to Baka
+    print(result);
+    contours = result.dstContours ?? [];
+    lesionSize = result.dstLesionSize;
+    keyPoints = await _queryAllKeyPoints();
+    return true;
+    // if (result.success) {
+    //   // _initData();
+    // }
+  }
+
+  /// 画布坐标系转换【画布坐标系 -> 接口坐标系】
+  List<AIDiagnosisPoint2D> _convertCanvasPoints(List<Offset> points) {
+    List<AIDiagnosisPoint2D> result = [];
+    for (Offset point in points) {
+      result.add(
+          AIDiagnosisPoint2D(x: point.dx ~/ _scale, y: point.dy ~/ _scale));
+    }
+    return result;
+  }
+
+  /// 关键点坐标转换【接口坐标系 -> 画布坐标系】同时更新横纵比例线段下标
+  List<Offset> _convertKeyPoints(List<DiagnosisKeyPointDTO> points) {
+    List<Offset> result = [];
+    List<int> 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;
+    return result;
+  }
+
+  /// 坐标转换【接口坐标系 -> 画布坐标系】
+  List<Offset> _convertPoints(List<AIDiagnosisPoint2D> points) {
+    List<Offset> result = [];
+    for (AIDiagnosisPoint2D point in points) {
+      result.add(
+          Offset(point.x.toDouble() * _scale, point.y.toDouble() * _scale));
+    }
+    return result;
+  }
+
+  ///  获取ai结果相关枚举集合
+  Future<void> _getDiagnosisEnumItemsAsync() async {
+    final getDiagnosisEnumItems =
+        await rpcHelper.rpc.aIDiagnosis.getDiagnosisEnumItemsAsync(
+      GetDiagnosisEnumItemsRequest(
+        token: rpcHelper.userToken,
+      ),
+    );
+    _diagnosisEnumItems = getDiagnosisEnumItems.source ?? [];
+    update(['ai_result_panel']);
+  }
+
+  void _initData() {
+    update(["ai_result_modifier"]);
+  }
+
+  /// 在拖拽模式下触发拖拽事件【每隔100ms触发一次】
+  void _onDragModeCallDragFunction(Offset pos) async {
+    print("鼠标拖拽 $_dragStartPoint -> $pos");
+    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);
+    print("影响到的关键点数量:${affectedKeyPointIndexes.length}");
+    _updateCurrAffectedKeyPoints();
+    update(["ai_result_canvas"]);
+  }
+
+  /// 在画轮廓模式下触发拖拽事件
+  void _onPenModeCallDragFunction(Offset pos) async {
+    if (!_isDrawingNewContours) return;
+    // 点间距【疏密程度】
+    const double pointDistance = 8;
+    final double distance = (pos - _canvasNewContoursPoints.last).distance;
+    print("当前点到上一个点的距离:$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"]);
+    }
+    print("当前轮廓点数量:${_canvasNewContoursPoints.length}");
+    _autoCloseContours();
+  }
+
+  /// 在画轮廓模式下,通过鼠标位置更新最近的关键点【每隔10ms触发一次】
+  void _onPenModeCallHoverFunction(Offset localPosition) async {
+    double minDistance = double.infinity;
+    // Offset nearestKeyPoint = Offset.zero;
+    int nearestKeyPointIndex = -1;
+
+    /// 遍历所有关键点keyPoints,找到离localPosition最近的关键点
+    for (int i = 0; i < canvasContoursPoints.length; i++) {
+      final point = canvasContoursPoints[i];
+      final double distance = (point - localPosition).distance;
+      if (distance < minDistance) {
+        minDistance = distance;
+        // nearestKeyPoint = point;
+        nearestKeyPointIndex = i;
+      }
+    }
+    _canvasPenModeKeyPointIndexes.clear();
+    if (minDistance < 10) {
+      _canvasPenModeKeyPointIndexes.add(nearestKeyPointIndex);
+    }
+    update(["ai_result_canvas"]);
+  }
+
+  /// 根据鼠标位置查询受影响的关键点
+  Future<List<int>> _queryAffectedKeyPoints(AIDiagnosisPoint2D mousePos) async {
+    try {
+      final List<int> result =
+          await rpcHelper.rpc.aIDiagnosis.affectedKeyPointsByDragActionAsync(
+        AffectedKeyPointsByDragActionRequest(
+          token: rpcHelper.userToken,
+          keyPoints: keyPoints,
+          mousePoint: mousePos,
+        ),
+      );
+      // print(result);
+      return result;
+    } catch (e) {
+      print(e);
+      return [];
+    }
+  }
+
+  /// 查询所有关键点【需要先存好contours和lesionSize】
+  Future<List<DiagnosisKeyPointDTO>> _queryAllKeyPoints() async {
+    try {
+      final List<DiagnosisKeyPointDTO> result =
+          await rpcHelper.rpc.aIDiagnosis.getKeyPointsOfContourAsync(
+        GetKeyPointsOfContourRequest(
+          token: rpcHelper.userToken,
+          contours: contours,
+          lesionSize: lesionSize,
+        ),
+      );
+      return result;
+    } catch (e) {
+      print(e);
+      return [];
+    }
+  }
+
+  /// 查询拖拽结果集合【需要先存好 contoursOnDragStart 和 keyPointsOnDragStart】
+  Future<bool> _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!;
+      print("拖拽结果:${keyPoints.length} ${contours.length}");
+      return true;
+    } catch (e) {
+      print(e);
+      return false;
+    }
+  }
+
+  /// [⭐ _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));
+      }
+    }
+    print("受影响的点数:${_canvasAffectedKeyPoints.length}");
+  }
+
+  /// [⭐ _canvasContoursPoints ] 更新当前轮廓点
+  void _updateCurrContoursPoints() {
+    _canvasContoursPoints = _convertPoints(contours);
+    print("轮廓点数:${_canvasContoursPoints.length}");
+  }
+
+  /// [⭐ _canvasKeyPoints ] 更新当前关键点
+  void _updateCurrKeyPoints() async {
+    _canvasKeyPoints = _convertKeyPoints(keyPoints);
+    print("关键点数:${_canvasKeyPoints.length}");
+  }
+}
+
+enum AiResultModifierMode {
+  /// 拖拽
+  drag,
+
+  /// 画笔
+  pen,
+}

+ 4 - 0
lib/view/ai_result_modifier/index.dart

@@ -0,0 +1,4 @@
+library ai_result_modifier;
+
+export './controller.dart';
+export './view.dart';

+ 11 - 0
lib/view/ai_result_modifier/state.dart

@@ -0,0 +1,11 @@
+import 'package:get/get.dart';
+
+class AiResultModifierState {
+  final Rx<int> _currentAiDetectedObjectIndex = Rx(0);
+
+  get currentAiDetectedObjectIndex => _currentAiDetectedObjectIndex.value;
+
+  /// 当前ai病灶下标
+  set currentAiDetectedObjectIndex(value) =>
+      _currentAiDetectedObjectIndex.value = value;
+}

+ 205 - 0
lib/view/ai_result_modifier/view.dart

@@ -0,0 +1,205 @@
+import 'package:fis_measure/define.dart';
+import 'package:fis_measure/view/ai_result_modifier/widgets/ai_conclusion_result.dart';
+import 'package:fis_ui/index.dart';
+import 'package:fis_ui/interface/interactive_container.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import 'index.dart';
+import 'widgets/ai_modifier_preview.dart';
+import 'widgets/ai_result_canvas.dart';
+
+// void _openMeasurePage(
+//     String patientCode, String remedicalCode, String recordCode) {
+//   var parasmeters = {
+//     "page": "measure",
+//     "patientCode": patientCode,
+//     "remedicalCode": remedicalCode,
+//     "recordCode": recordCode,
+//     "token": Store.user.token
+//   };
+
+//   /// FIXME: 临时劫持测量入口
+//   _tempOpenAiResultModifierDialog(parasmeters);
+//   // router.to(RouteNames.Remedical.Measure,
+//   //     id: NavIds.HOME, parameters: parasmeters);
+// }
+
+// void _tempOpenAiResultModifierDialog(Map<String, String> parasmeters) {
+//   Get.dialog(
+//     AiResultModifierDialog(),
+//     arguments: parasmeters,
+//   );
+// }
+
+class AiResultModifierDialog extends FisView<AiResultModifierController>
+    implements FInteractiveContainer {
+  const AiResultModifierDialog({Key? key}) : super(key: key);
+
+  @override
+  String get pageName => "ai_result_modifier";
+
+  @override
+  FWidget build(BuildContext context) {
+    return FisBuilder<AiResultModifierController>(
+      init: AiResultModifierController(),
+      id: "ai_result_modifier",
+      builder: (_) {
+        return FDialog(
+          shape: RoundedRectangleBorder(
+            borderRadius: BorderRadius.circular(8),
+          ),
+          clipBehavior: Clip.hardEdge,
+          child: FContainer(
+            width: Get.width * 0.9,
+            height: Get.height * 0.9,
+            child: FColumn(
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                FExpanded(child: _buildAiResultModifier()),
+                _buildDebugButtons(),
+              ],
+            ),
+          ),
+        );
+      },
+    );
+  }
+
+  FWidget _buildAiResult() {
+    return FContainer(
+      width: 400,
+      color: Colors.black,
+      child: FisBuilder<AiResultModifierController>(
+        id: 'ai_result_panel',
+        builder: (_) {
+          if (_.aiDetectedObjectList.isNotEmpty) {
+            return const AiConclusionResult();
+          }
+          return const FSizedBox();
+        },
+      ),
+    );
+  }
+
+  FWidget _buildAiResultModifier() {
+    return FRow(
+      children: [
+        FExpanded(child: _buildImageArea()),
+        _buildAiResult(),
+      ],
+    );
+  }
+
+  // 操作按钮
+  FWidget _buildDebugButtons() {
+    return FContainer(
+      height: 50,
+      child: FRow(
+        children: [
+          const FSizedBox(width: 20),
+          const FText("AiResultModifierDialog WIP"),
+          const FSizedBox(width: 20),
+          FOutlinedButton(
+              child: const FText("加载AI结果"),
+              onPressed: () {
+                controller.loadAIResult();
+              },
+              businessParent: this,
+              name: "debug btn"),
+          const FSizedBox(width: 20),
+          FOutlinedButton(
+              child: const FText("切换为画轮廓模式"),
+              onPressed: () {
+                controller.changeModifierMode(AiResultModifierMode.pen);
+              },
+              businessParent: this,
+              name: "debug btn"),
+          const FSizedBox(width: 20),
+          FOutlinedButton(
+              child: const FText("切换为拖拽模式"),
+              onPressed: () {
+                controller.changeModifierMode(AiResultModifierMode.drag);
+              },
+              businessParent: this,
+              name: "debug btn"),
+          const FSizedBox(width: 20),
+          FOutlinedButton(
+              child: const FText("保存AI结果"),
+              onPressed: () {
+                controller.saveAIResult();
+              },
+              businessParent: this,
+              name: "debug btn"),
+        ],
+      ),
+    );
+  }
+
+  // 图像区域
+  FWidget _buildImageArea() {
+    return FContainer(
+      color: const Color.fromARGB(255, 54, 55, 56),
+      child: FCenter(
+        child: FStack(
+          key: controller.framePlayerKey,
+          children: [
+            FAIModifierPreview(
+              vidUrl:
+                  "http://cdn-bj.fis.plus/3005AE58DFBE4B4B8062E8E2A1404BB9.vid",
+              onFrameSizeLoaded: controller.onFrameDataLoaded,
+            ),
+            FisBuilder<AiResultModifierController>(
+              id: 'ai_result_canvas',
+              builder: (_) {
+                return FCenter(
+                  child: FCustomPaint(
+                    painter: AIResultCanvas(
+                      contoursPoints: _.canvasContoursPoints,
+                      newContoursPoints: _.canvasNewContoursPoints,
+                      keyPoints: _.canvasKeyPoints,
+                      highlightKeyPoints: _.canvasAffectedKeyPoints,
+                      lesionSizePointsIndexes: _.canvasLesionSizePointsIndexes,
+                      penModeKeyPointIndexes: _.canvasPenModeKeyPointIndexes,
+                      currMode: _.currMode,
+                    ),
+                    size: _.aiCanvasSize,
+                  ),
+                );
+              },
+            ),
+            FisBuilder<AiResultModifierController>(
+              id: 'ai_result_modifier_interactive_layer',
+              builder: (_) {
+                return FCenter(
+                  child: FMouseRegion(
+                    onHover: (event) => _.onMouseHover(event),
+                    child: FGestureDetector(
+                        behavior: HitTestBehavior.opaque,
+                        businessParent: this,
+                        name: "ai_result_modifier_interactive_layer",
+                        onPanDown: (e) {
+                          _.onMouseDragStart(e);
+                        },
+                        onPanUpdate: (e) {
+                          _.onMouseDrag(e);
+                        },
+                        onPanEnd: (e) {
+                          _.onMouseDragEnd(e);
+                        },
+                        dragStartBehavior: DragStartBehavior.down,
+                        child: FContainer(
+                          width: _.aiCanvasSize.width,
+                          height: _.aiCanvasSize.height,
+                        )),
+                  ),
+                );
+              },
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 95 - 0
lib/view/ai_result_modifier/widgets/ai_conclusion_result.dart

@@ -0,0 +1,95 @@
+import 'package:fis_measure/define.dart';
+import 'package:fis_measure/view/ai_result_modifier/controller.dart';
+import 'package:fis_measure/view/ai_result_modifier/widgets/ai_feature_analysis.dart';
+import 'package:fis_ui/base_define/page.dart';
+import 'package:fis_ui/index.dart';
+import 'package:flutter/material.dart';
+
+import 'ai_diagnostic_result.dart';
+
+class AiConclusionResult extends FisView<AiResultModifierController>
+    implements FPage {
+  const AiConclusionResult({Key? key}) : super(key: key);
+  @override
+  String get pageName => 'AiConclusionResult';
+
+  @override
+  FWidget build(BuildContext context) {
+    return FColumn(
+      children: [
+        _buildAiDetectedObjects(),
+        FObx(
+          () => AiDiagnosticResult(
+            controller.aiDetectedObjectList[
+                controller.state.currentAiDetectedObjectIndex],
+            controller.diagnosisOrgan,
+          ),
+        ),
+        const FSizedBox(
+          height: 15,
+        ),
+        FExpanded(
+          child: FObx(
+            () => AiFeatureAnalysis(
+              controller
+                  .aiDetectedObjectList[
+                      controller.state.currentAiDetectedObjectIndex]
+                  .descriptions,
+            ),
+          ),
+        ),
+      ],
+    );
+  }
+
+  /// 多个病灶的下标
+  FWidget _buildAiDetectedObjects() {
+    if (controller.aiDetectedObjectList.length > 1) {
+      return FContainer(
+        alignment: Alignment.centerLeft,
+        margin: const EdgeInsets.symmetric(vertical: 15),
+        child: FWrap(
+          spacing: 2.0, // 主轴(水平)方向间距
+          runSpacing: 2.0, // 纵轴(垂直)方向间距
+          direction: Axis.horizontal,
+          alignment: WrapAlignment.start,
+          children: List<FWidget>.generate(
+            controller.aiDetectedObjectList.length,
+            (index) {
+              return FGestureDetector(
+                onTap: () {
+                  controller.state.currentAiDetectedObjectIndex = index;
+                },
+                businessParent: this,
+                name: 'AiDetectedObjects',
+                child: FObx(
+                  () => FContainer(
+                    decoration: BoxDecoration(
+                      color:
+                          controller.state.currentAiDetectedObjectIndex == index
+                              ? const Color.fromRGBO(54, 169, 206, 1)
+                              : Colors.grey,
+                    ),
+                    width: 38,
+                    height: 35,
+                    child: FCenter(
+                      child: FText(
+                        '${index + 1}',
+                        style: const TextStyle(
+                          color: Colors.white,
+                        ),
+                      ),
+                    ),
+                  ),
+                ),
+              );
+            },
+          ),
+        ),
+        color: Colors.transparent,
+      );
+    } else {
+      return const FSizedBox();
+    }
+  }
+}

+ 331 - 0
lib/view/ai_result_modifier/widgets/ai_diagnostic_result.dart

@@ -0,0 +1,331 @@
+// ignore_for_file: must_be_immutable
+
+import 'package:fis_i18n/i18n.dart';
+import 'package:fis_jsonrpc/rpc.dart';
+import 'package:fis_measure/define.dart';
+import 'package:fis_ui/index.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:sleek_circular_slider/sleek_circular_slider.dart';
+
+import '../controller.dart';
+
+/// AI诊断结果
+class AiDiagnosticResult extends FisView<AiResultModifierController> {
+  final AIDetectedObject aiDetectedObject;
+
+  final DiagnosisOrganEnum diagnosisOrgan;
+  const AiDiagnosticResult(
+    this.aiDetectedObject,
+    this.diagnosisOrgan, {
+    Key? key,
+  }) : super(key: key);
+
+  // /// 获取到横纵直线
+  // String? get isLesionSizeDescriptionValue =>
+  //     aiDetectedObject.descriptions?.last.value;
+
+  // /// 获取到横纵线的对象
+  // late Map<String, dynamic> horizontalAndVerticalLine =
+  //     jsonDecode(isLesionSizeDescriptionValue ?? '');
+
+  @override
+  FWidget build(BuildContext context) {
+    return FContainer(
+      decoration: BoxDecoration(
+        border: Border.all(
+          color: Colors.grey,
+        ),
+        borderRadius: BorderRadius.circular(4),
+        color: Colors.transparent,
+      ),
+      padding: const EdgeInsets.only(bottom: 10),
+      child: FColumn(
+        children: [
+          FRow(
+            mainAxisSize: MainAxisSize.max,
+            children: [
+              FExpanded(
+                child: FContainer(
+                  decoration: const BoxDecoration(
+                    borderRadius: BorderRadius.only(
+                      topLeft: Radius.circular(4),
+                      topRight: Radius.circular(4),
+                    ),
+                    color: Color.fromRGBO(54, 169, 206, 1),
+                  ),
+                  padding: const EdgeInsets.only(
+                    top: 4,
+                    bottom: 10,
+                    left: 8,
+                    right: 8,
+                  ),
+                  child: FText(
+                    i18nBook.measure.aiDiagnosticResults.t,
+                    style: const TextStyle(
+                      color: Colors.white,
+                    ),
+                  ),
+                ),
+              )
+            ],
+          ),
+          _buildPossibilityProgressBar(),
+        ],
+      ),
+    );
+  }
+
+  FWidget _aiResultValue() {
+    List<FSelectOptionModel> source = _initEnumFieldList();
+    return FContainer(
+      margin: const EdgeInsets.only(top: 10),
+      child: FSelect<FSelectOptionModel, int>(
+        height: 38,
+        source: source,
+        optionValueExtractor: (data) => data.value,
+        optionLabelExtractor: (data) => data.title,
+        value: aiDetectedObject.label,
+        textColor: Colors.white,
+        onSelectChanged: (value, index) {
+          aiDetectedObject.label = index!;
+        },
+      ),
+    );
+  }
+
+  Color _buildAITextColor(int label) {
+    switch (DiagnosisOrganEnum.Breast) {
+      /// 乳腺是0:未见异常; 1、2、3良性; 4、5、6、7恶性;
+      case DiagnosisOrganEnum.Breast:
+        switch (label) {
+          case 0:
+            return Colors.lightBlue;
+          case 1:
+          case 2:
+          case 3:
+            return Colors.greenAccent;
+          case 4:
+          case 5:
+          case 6:
+          case 7:
+            return Colors.redAccent;
+          default:
+            return Colors.lightBlue;
+        }
+
+      /// 肝脏是0:未见异常; 1、2、3、4、5、6、7、8良性;
+      case DiagnosisOrganEnum.Liver:
+        switch (label) {
+          case 0:
+            return Colors.lightBlue;
+          case 4:
+            return Colors.redAccent;
+          case 1:
+          case 2:
+          case 3:
+          case 5:
+          case 6:
+          case 7:
+          case 8:
+            return Colors.greenAccent;
+          default:
+            return Colors.lightBlue;
+        }
+
+      /// 甲状腺是0:未见异常; 1、2、3、4、5、6、7、8良性;
+      case DiagnosisOrganEnum.Thyroid:
+        switch (label) {
+          case 0:
+            return Colors.lightBlue;
+          case 1:
+          case 2:
+
+          case 7:
+            return Colors.greenAccent;
+          case 3:
+          case 4:
+          case 5:
+          case 6:
+            return Colors.redAccent;
+          default:
+            return Colors.lightBlue;
+        }
+      default:
+        return Colors.lightBlue;
+    }
+  }
+
+  String _buildAITitle() {
+    switch (diagnosisOrgan) {
+      case DiagnosisOrganEnum.Breast:
+        return 'Breast';
+      case DiagnosisOrganEnum.Liver:
+        return 'Liver';
+      case DiagnosisOrganEnum.Thyroid:
+        return 'Thyroid';
+      default:
+        return '';
+    }
+  }
+
+  /// TODO BAKA 需要拿一下图像里面的单位
+  FWidget _buildLesionSize(
+    int horizontalLengthInPixel,
+    int verticalLengthInPixel,
+    double unitsPhysicalPixels,
+  ) {
+    return FContainer(
+      margin: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
+      child: FText(
+        (horizontalLengthInPixel / 100 * unitsPhysicalPixels)
+                .toStringAsFixed(2)
+                .toString() +
+            'cm x' +
+            (verticalLengthInPixel / 100 * unitsPhysicalPixels)
+                .toStringAsFixed(2)
+                .toString() +
+            'cm',
+        style: const TextStyle(color: Colors.white),
+      ),
+    );
+  }
+
+  FWidget _buildPossibilityProgressBar() {
+    return FRow(
+      mainAxisAlignment: MainAxisAlignment.spaceBetween,
+      children: [
+        FExpanded(
+          child: FContainer(
+            padding: const EdgeInsets.only(
+              left: 10,
+            ),
+            child: FColumn(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              mainAxisAlignment: MainAxisAlignment.start,
+              children: [
+                _buildTitle(
+                  i18nBook.measure.diseaseLabels.t,
+                  _aiResultValue(),
+                ),
+                const FSizedBox(
+                  height: 15,
+                ),
+                _buildTitle(
+                  i18nBook.measure.isLesionSize.t,
+                  _buildLesionSize(
+                    controller.horizontalLengthInPixel,
+                    controller.verticalLengthInPixel,
+                    controller.scale,
+                  ),
+                )
+              ],
+            ),
+          ),
+        ),
+        FContainer(
+          margin: const EdgeInsets.symmetric(
+            vertical: 5,
+          ),
+          padding: const EdgeInsets.only(right: 8),
+          child: FColumn(
+            crossAxisAlignment: CrossAxisAlignment.center,
+            children: [
+              FText(
+                i18nBook.measure.possibility.t,
+                style: const TextStyle(
+                  color: Colors.white,
+                ),
+              ),
+              const FSizedBox(
+                height: 8,
+              ),
+              FSizedBox(
+                width: 100,
+                height: 100,
+                child: QuickFWidget(
+                  SleekCircularSlider(
+                    initialValue: aiDetectedObject.confidence * 100,
+                    appearance: CircularSliderAppearance(
+                      customWidths: CustomSliderWidths(
+                        progressBarWidth: 4,
+                        shadowWidth: 0,
+                      ),
+                      customColors: CustomSliderColors(
+                        progressBarColors: [
+                          _buildAITextColor(
+                            aiDetectedObject.label,
+                          ),
+                          _buildAITextColor(
+                            aiDetectedObject.label,
+                          ),
+                        ],
+                        gradientStartAngle: 0,
+                        gradientEndAngle: 0,
+                        trackColor: _buildAITextColor(
+                          aiDetectedObject.label,
+                        ),
+                      ),
+                      infoProperties: InfoProperties(
+                        mainLabelStyle: const TextStyle(
+                          fontSize: 22,
+                          color: Colors.white,
+                        ),
+                      ),
+                      angleRange: 360,
+                    ),
+                    onChange: (double value) {
+                      aiDetectedObject.confidence = value;
+                    },
+                  ),
+                ),
+              ),
+            ],
+          ),
+        ),
+      ],
+    );
+  }
+
+  FWidget _buildTitle(String label, FWidget value) {
+    return FColumn(
+      mainAxisSize: MainAxisSize.max,
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        FText(
+          label,
+          style: const TextStyle(
+            color: Color.fromRGBO(54, 169, 206, 1),
+          ),
+        ),
+        // const FSizedBox(
+        //   height: 10,
+        // ),
+        value,
+        // const FSizedBox(
+        //   height: 5,
+        // ),
+      ],
+    );
+  }
+
+  List<FSelectOptionModel> _initEnumFieldList() {
+    List<FSelectOptionModel> source = [];
+    if (controller.diagnosisEnumItems.isNotEmpty) {
+      List<EnumFieldDTO> enumFieldList =
+          controller.diagnosisEnumItems.firstWhereOrNull((element) {
+                return element.code == _buildAITitle();
+              })?.children ??
+              [];
+      for (var element in enumFieldList) {
+        source.add(
+          FSelectOptionModel(
+            title: controller.getValuesFromAiLanguage(element.value ?? ''),
+            value: element.id,
+          ),
+        );
+      }
+    }
+    return source;
+  }
+}

+ 258 - 0
lib/view/ai_result_modifier/widgets/ai_feature_analysis.dart

@@ -0,0 +1,258 @@
+// ignore_for_file: must_be_immutable
+
+import 'dart:convert';
+
+import 'package:collection/collection.dart';
+import 'package:fis_i18n/i18n.dart';
+import 'package:fis_jsonrpc/rpc.dart';
+import 'package:fis_measure/define.dart';
+import 'package:fis_ui/index.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import '../controller.dart';
+
+/// ai特征分析
+class AiFeatureAnalysis extends FisView<AiResultModifierController> {
+  List<AIDiagnosisDescription>? descriptions;
+
+  final _scrollController = ScrollController();
+  AiFeatureAnalysis(this.descriptions, {super.key});
+  @override
+  FWidget build(BuildContext context) {
+    return FContainer(
+      decoration: BoxDecoration(
+        border: Border.all(
+          color: const Color.fromRGBO(54, 169, 206, 1),
+        ),
+        borderRadius: BorderRadius.circular(4),
+        color: Colors.transparent,
+      ),
+      child: FColumn(
+        children: [
+          FRow(
+            mainAxisSize: MainAxisSize.max,
+            children: [
+              FExpanded(
+                child: FContainer(
+                  decoration: const BoxDecoration(
+                    borderRadius: BorderRadius.only(
+                      topLeft: Radius.circular(4),
+                      topRight: Radius.circular(4),
+                    ),
+                    color: Color.fromRGBO(54, 169, 206, 1),
+                  ),
+                  padding: const EdgeInsets.symmetric(
+                    vertical: 4,
+                    horizontal: 8,
+                  ),
+                  child: FText(
+                    i18nBook.measure.featureAnalysis.t,
+                    style: const TextStyle(
+                      color: Colors.white,
+                    ),
+                  ),
+                ),
+              )
+            ],
+          ),
+          FExpanded(
+            child: _aiFeatureAnalysisFScrollbar(),
+          ),
+        ],
+      ),
+    );
+  }
+
+  FWidget _aiFeatureAnalysisFScrollbar() {
+    return FScrollbar(
+      isAlwaysShown: true,
+      controller: _scrollController,
+      child: FGridView.count(
+        shrinkWrap: true,
+        crossAxisCount: 2,
+        childAspectRatio: 2,
+        controller: _scrollController,
+        children: descriptions!.mapIndexed((index, e) {
+          return FContainer(
+            decoration: BoxDecoration(
+              border: Border.all(
+                color: const Color.fromRGBO(54, 169, 206, 1),
+                width: 1,
+              ),
+            ),
+            padding: const EdgeInsets.all(4),
+            child: FColumn(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                FContainer(
+                  margin: const EdgeInsets.only(
+                    top: 5,
+                    left: 5,
+                  ),
+                  child: FTooltip(
+                    message: _buildDescriptionType(e.type,
+                        isDiagnosisDescriptionTitle: false),
+                    child: FText(
+                      _buildDescriptionType(e.type,
+                          isDiagnosisDescriptionTitle: false),
+                      style: const TextStyle(
+                        color: Color.fromRGBO(54, 169, 206, 1),
+                      ),
+                      overflow: TextOverflow.ellipsis,
+                      maxLines: 1,
+                    ),
+                    textLines: const [],
+                  ),
+                ),
+                e.type != DiagnosisDescriptionEnum.LesionSize
+                    ? FExpanded(
+                        child: FCenter(
+                          child: FContainer(
+                            margin: const EdgeInsets.symmetric(horizontal: 5),
+                            child: _AiResultValue(index, e),
+                          ),
+                        ),
+                      )
+
+                    /// TODO BAKA 真实的横纵比
+                    : const FExpanded(
+                        child: FCenter(
+                          child: FText(
+                            '> 1',
+                            style: TextStyle(color: Colors.white),
+                          ),
+                        ),
+                      ),
+              ],
+            ),
+          );
+        }).toList(),
+      ),
+    );
+  }
+
+  FWidget _AiResultValue(
+    int aiResultindex,
+    AIDiagnosisDescription aiDiagnosisDescription,
+  ) {
+    List<FSelectOptionModel<String>> source =
+        _initEnumFieldList(aiResultindex, aiDiagnosisDescription.type);
+    return FSelect<FSelectOptionModel<String>, String>(
+      height: 38,
+      source: source,
+      optionValueExtractor: (FSelectOptionModel<String> data) => data.value,
+      optionLabelExtractor: (data) => data.title,
+      value: jsonEncode(AiFeatureAnalysisResult(
+        currentIndex: aiResultindex,
+        resultValue: aiDiagnosisDescription.value ?? '',
+      ).toJson()),
+      textColor: Colors.white,
+      onSelectChanged: (value, index) {
+        descriptions?[jsonDecode(value!)?['currentIndex'] ?? 0].value =
+            jsonDecode(value)!['resultValue'];
+      },
+    );
+  }
+
+  String _buildDescriptionType(
+    DiagnosisDescriptionEnum diagnosisDescriptionType, {
+    bool isDiagnosisDescriptionTitle = true,
+  }) {
+    final Map<DiagnosisDescriptionEnum, String> titleMap = {
+      DiagnosisDescriptionEnum.Shape: 'Shape',
+      DiagnosisDescriptionEnum.Orientation: 'Orientation',
+      DiagnosisDescriptionEnum.EchoPattern: 'EchoPattern',
+      DiagnosisDescriptionEnum.LesionBoundary: 'LesionBoundary',
+      DiagnosisDescriptionEnum.Margin: 'Margin',
+      DiagnosisDescriptionEnum.Calcification: 'Calcification',
+      DiagnosisDescriptionEnum.ThyroidEchoPattern: 'ThyroidEchoPattern',
+      DiagnosisDescriptionEnum.ThyroidShape: 'ThyroidShape',
+      DiagnosisDescriptionEnum.ThyroidMargin: 'ThyroidMargin',
+      DiagnosisDescriptionEnum.ThyroidEchogenicFoci: 'ThyroidEchogenicFoci',
+      DiagnosisDescriptionEnum.LiverShape: 'LiverShape',
+      DiagnosisDescriptionEnum.LiverBoundary: 'LiverBoundary',
+      DiagnosisDescriptionEnum.LiverEchoTexture: 'LiverEchoTexture',
+      DiagnosisDescriptionEnum.LesionSize: 'LesionSize',
+    };
+    final Map<DiagnosisDescriptionEnum, String> descMap = {
+      DiagnosisDescriptionEnum.Shape: i18nBook.measure.shape.t,
+      DiagnosisDescriptionEnum.Orientation: i18nBook.measure.orientation.t,
+      DiagnosisDescriptionEnum.EchoPattern: i18nBook.measure.echoPattern.t,
+      DiagnosisDescriptionEnum.LesionBoundary:
+          i18nBook.measure.lesionBoundary.t,
+      DiagnosisDescriptionEnum.Margin: i18nBook.measure.margin.t,
+      DiagnosisDescriptionEnum.Calcification: i18nBook.measure.calcification.t,
+      DiagnosisDescriptionEnum.ThyroidEchoPattern:
+          i18nBook.measure.echoPattern.t,
+      DiagnosisDescriptionEnum.ThyroidShape: i18nBook.measure.shape.t,
+      DiagnosisDescriptionEnum.ThyroidMargin: i18nBook.measure.margin.t,
+      DiagnosisDescriptionEnum.ThyroidEchogenicFoci:
+          i18nBook.measure.thyroidEchogenicFoci.t,
+      DiagnosisDescriptionEnum.LiverShape: i18nBook.measure.shape.t,
+      DiagnosisDescriptionEnum.LiverBoundary: i18nBook.measure.lesionBoundary.t,
+      DiagnosisDescriptionEnum.LiverEchoTexture:
+          i18nBook.measure.liverEchoTexture.t,
+      DiagnosisDescriptionEnum.LesionSize: i18nBook.measure.lesionSize.t,
+    };
+    const String defaultTitle = 'Shape';
+    const String defaultDesc = '';
+
+    if (isDiagnosisDescriptionTitle) {
+      return titleMap[diagnosisDescriptionType] ?? defaultTitle;
+    } else {
+      return descMap[diagnosisDescriptionType] ?? defaultDesc;
+    }
+  }
+
+  List<FSelectOptionModel<String>> _initEnumFieldList(
+    int aiResultindex,
+    DiagnosisDescriptionEnum type,
+  ) {
+    List<FSelectOptionModel<String>> source = [];
+    if (controller.diagnosisEnumItems.isNotEmpty) {
+      List<EnumFieldDTO> enumFieldList =
+          controller.diagnosisEnumItems.firstWhereOrNull((element) {
+                return element.code == _buildDescriptionType(type);
+              })?.children ??
+              [];
+      for (int i = 0; i < enumFieldList.length; i++) {
+        source.add(
+          FSelectOptionModel<String>(
+            title: controller
+                .getValuesFromAiLanguage(enumFieldList[i].value ?? ''),
+            value: jsonEncode(AiFeatureAnalysisResult(
+              currentIndex: aiResultindex,
+              resultValue: enumFieldList[i].value ?? '',
+            ).toJson()),
+          ),
+        );
+      }
+    }
+    return source;
+  }
+}
+
+class AiFeatureAnalysisResult {
+  final String resultValue;
+
+  final int currentIndex;
+  AiFeatureAnalysisResult({
+    required this.resultValue,
+    required this.currentIndex,
+  });
+
+  factory AiFeatureAnalysisResult.fromJson(Map<String, dynamic> map) {
+    return AiFeatureAnalysisResult(
+      resultValue: map['resultValue'],
+      currentIndex: map['currentIndex'],
+    );
+  }
+
+  Map<String, dynamic> toJson() {
+    Map<String, dynamic> map = {};
+    map['resultValue'] = resultValue;
+    map['currentIndex'] = currentIndex;
+    return map;
+  }
+}

+ 102 - 0
lib/view/ai_result_modifier/widgets/ai_modifier_preview.dart

@@ -0,0 +1,102 @@
+import 'package:fis_i18n/i18n.dart';
+import 'package:fis_measure/define.dart';
+import 'package:fis_measure/index.dart';
+import 'package:fis_measure/utils/prompt_box.dart';
+import 'package:fis_measure/view/player/controller_old.dart';
+import 'package:fis_ui/index.dart';
+import 'package:fis_vid/data_host/data_host.dart';
+import 'package:flutter/material.dart';
+
+/// 用于浏览指定帧的简单播放器
+class FAIModifierPreview extends FStatefulWidget {
+  final String vidUrl;
+  final double? width;
+  final double? height;
+  final ValueCallback<Size>? onFrameSizeLoaded;
+
+  const FAIModifierPreview(
+      {super.key,
+      required this.vidUrl,
+      this.width,
+      this.height,
+      this.onFrameSizeLoaded});
+
+  @override
+  _FQuickPreviewState createState() => _FQuickPreviewState();
+}
+
+class _FQuickPreviewState extends FState<FAIModifierPreview> {
+  late final VidPlayerControllerNoSharing _playerController;
+  bool isShowLoading = true;
+
+  @override
+  FWidget build(BuildContext context) {
+    return FStack(children: <FWidget>[
+      // 使用 Offstage 组件来控制第二个组件的可见性
+      FCenter(
+        child: FRepaintBoundary(
+          child: QuickFWidget(
+            VidPlayer(_playerController),
+          ),
+        ),
+      ),
+      FCenter(
+        child: FOffstage(
+          child: FContainer(
+            padding: const EdgeInsets.all(10),
+            width: 50,
+            height: 50,
+            child: const FCircularProgressIndicator(),
+          ),
+          offstage: !isShowLoading,
+        ),
+      ),
+    ]);
+  }
+
+  @override
+  void dispose() {
+    _playerController.dispose();
+    super.dispose();
+  }
+
+  @override
+  void initState() {
+    final dataHost = VidDataHost(widget.vidUrl);
+    final playerController = VidPlayerControllerNoSharing(dataHost: dataHost);
+    _playerController = playerController;
+    loadVidDataHost(_playerController);
+    super.initState();
+  }
+
+  void loadVidDataHost(VidPlayerControllerNoSharing playerController) async {
+    int retryCount = 0;
+    while (retryCount < 3) {
+      try {
+        bool success = await playerController.load();
+        if (!success) {
+          throw "loadVidDataHost failed";
+        }
+        setState(() {
+          isShowLoading = false;
+        });
+        playerController.play();
+        await Future.delayed(const Duration(milliseconds: 100));
+        Size size = Size(playerController.currentFrame!.width.toDouble(),
+            playerController.currentFrame!.height.toDouble());
+        widget.onFrameSizeLoaded?.call(size);
+        return;
+      } catch (e) {
+        retryCount++;
+        if (retryCount == 3) {
+          PromptBox.toast(i18nBook.common.NetImageError.t);
+          setState(() {
+            isShowLoading = false;
+          });
+        } else if (!mounted) {
+          return; // 如果组件已经被销毁,则停止重试
+        }
+      }
+    }
+  }
+}

+ 192 - 0
lib/view/ai_result_modifier/widgets/ai_result_canvas.dart

@@ -0,0 +1,192 @@
+import 'dart:math';
+import 'dart:ui';
+
+import 'package:fis_measure/view/ai_result_modifier/index.dart';
+import 'package:flutter/material.dart';
+
+class AIResultCanvas extends CustomPainter {
+  final List<Offset> contoursPoints;
+  final List<Offset> newContoursPoints;
+  final List<Offset> keyPoints;
+  final List<Offset> highlightKeyPoints;
+  final List<int> lesionSizePointsIndexes;
+  final List<int> penModeKeyPointIndexes;
+
+  final AiResultModifierMode currMode;
+
+  AIResultCanvas({
+    required this.contoursPoints,
+    required this.newContoursPoints,
+    required this.keyPoints,
+    required this.highlightKeyPoints,
+    required this.currMode,
+    required this.lesionSizePointsIndexes,
+    required this.penModeKeyPointIndexes,
+  });
+
+  /// 绘制基础轮廓
+  void handleBase(Canvas canvas) {
+    // 设置画笔
+    final contoursPaint = Paint()
+      ..color = const Color.fromARGB(255, 218, 165, 32)
+      ..strokeCap = StrokeCap.round
+      ..strokeWidth = 2.0
+      ..style = PaintingStyle.stroke;
+
+    // 为每个点创建路径,并绘制轮廓
+    canvas.drawPoints(PointMode.polygon, contoursPoints, contoursPaint);
+    // 连接起点终点
+    if (contoursPoints.isNotEmpty) {
+      Path path = Path();
+      path.moveTo(contoursPoints[0].dx, contoursPoints[0].dy);
+      path.lineTo(contoursPoints[contoursPoints.length - 1].dx,
+          contoursPoints[contoursPoints.length - 1].dy);
+      canvas.drawPath(path, contoursPaint);
+    }
+
+    // 连接横纵比线段
+    if (lesionSizePointsIndexes.length >= 2) {
+      final lesionSizeLinePaint = Paint()
+        ..color = const Color.fromARGB(255, 187, 250, 255)
+        ..strokeCap = StrokeCap.round
+        ..strokeWidth = 2.0
+        ..style = PaintingStyle.stroke;
+
+      Path path = Path();
+      path.moveTo(keyPoints[lesionSizePointsIndexes[0]].dx,
+          keyPoints[lesionSizePointsIndexes[0]].dy);
+      path.lineTo(keyPoints[lesionSizePointsIndexes[1]].dx,
+          keyPoints[lesionSizePointsIndexes[1]].dy);
+      canvas.drawPath(path, lesionSizeLinePaint);
+    }
+    if (lesionSizePointsIndexes.length >= 4) {
+      final lesionSizeLinePaint = Paint()
+        ..color = const Color.fromARGB(255, 255, 254, 187)
+        ..strokeCap = StrokeCap.round
+        ..strokeWidth = 2.0
+        ..style = PaintingStyle.stroke;
+
+      Path path = Path();
+      path.moveTo(keyPoints[lesionSizePointsIndexes[2]].dx,
+          keyPoints[lesionSizePointsIndexes[2]].dy);
+      path.lineTo(keyPoints[lesionSizePointsIndexes[3]].dx,
+          keyPoints[lesionSizePointsIndexes[3]].dy);
+      canvas.drawPath(path, lesionSizeLinePaint);
+    }
+  }
+
+  /// 拖拽模式
+  void handleDragMode(Canvas canvas) {
+    // 绘制关键点,每个关键点处打个绿点
+    final keyPointPaint = Paint()
+      ..color = const Color.fromARGB(255, 143, 188, 143)
+      ..strokeCap = StrokeCap.round
+      ..strokeWidth = 3.0;
+    if (keyPoints.isNotEmpty) {
+      for (int i = 0; i < keyPoints.length; i++) {
+        if (lesionSizePointsIndexes.contains(i)) {
+          paintX(
+            canvas,
+            keyPoints[i],
+            6.0,
+            3.0,
+            const Color.fromARGB(255, 235, 166, 62),
+          );
+        } else {
+          canvas.drawCircle(
+              Offset(keyPoints[i].dx, keyPoints[i].dy), 3.0, keyPointPaint);
+        }
+      }
+    }
+
+    // 绘制高亮关键点,每个关键点处打个红点
+    final highlightKeyPointsPaint = Paint()
+      ..color = const Color.fromARGB(255, 255, 81, 81)
+      ..strokeCap = StrokeCap.round
+      ..strokeWidth = 3.0;
+    if (highlightKeyPoints.isNotEmpty) {
+      for (int i = 0; i < highlightKeyPoints.length; i++) {
+        canvas.drawCircle(
+            Offset(highlightKeyPoints[i].dx, highlightKeyPoints[i].dy),
+            3.5,
+            highlightKeyPointsPaint);
+      }
+
+      /// 当选中横纵顶点时,叠加特殊处理
+      if (highlightKeyPoints.length == 1) {
+        // 遍历 lesionSizePointsIndexes 查找是否有跟关键点重合的顶点
+        for (int i = 0; i < lesionSizePointsIndexes.length; i++) {
+          final xPoint = keyPoints[lesionSizePointsIndexes[i]];
+          if (xPoint == highlightKeyPoints[0]) {
+            paintX(
+              canvas,
+              xPoint,
+              6.0,
+              3.5,
+              const Color.fromARGB(255, 255, 81, 81),
+            );
+          }
+        }
+      }
+    }
+  }
+
+  /// 画轮廓模式
+  void handlePenMode(Canvas canvas) {
+    // 遍历 penModeKeyPointIndexes 绘制高亮点
+    final highlightKeyPointsPaint = Paint()
+      ..color = const Color.fromARGB(255, 96, 255, 81)
+      ..strokeWidth = 10.0;
+    for (int i = 0; i < penModeKeyPointIndexes.length; i++) {
+      int index = penModeKeyPointIndexes[i];
+      if (index >= 0 && index < contoursPoints.length) {
+        canvas.save();
+        canvas.translate(contoursPoints[index].dx, contoursPoints[index].dy);
+        canvas.rotate(pi / 4);
+        canvas.drawRect(
+            Rect.fromCenter(
+                center: const Offset(0.0, 0.0), width: 10.0, height: 10.0),
+            highlightKeyPointsPaint);
+        canvas.restore();
+      }
+    }
+
+    // 设置画笔
+    final newContoursPaint = Paint()
+      ..color = const Color.fromARGB(255, 32, 218, 103)
+      ..strokeCap = StrokeCap.round
+      ..strokeWidth = 4.0
+      ..style = PaintingStyle.stroke;
+    canvas.drawPoints(PointMode.points, newContoursPoints, newContoursPaint);
+  }
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    handleBase(canvas);
+    if (currMode == AiResultModifierMode.pen) {
+      handlePenMode(canvas);
+    } else {
+      handleDragMode(canvas);
+    }
+  }
+
+  /// 绘制叉叉
+  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);
+  }
+
+  @override
+  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
+}

+ 27 - 2
lib/view/paint/parts/ai_result.dart

@@ -1,4 +1,6 @@
 import 'package:fis_i18n/i18n.dart';
+import 'package:fis_measure/process/workspace/measure_data_controller.dart';
+import 'package:fis_measure/view/ai_result_modifier/view.dart';
 import 'package:fis_measure/view/paint/ai_patint_controller.dart';
 import 'package:fis_measure/view/paint/date_structure.dart';
 import 'package:fis_measure/view/paint/parts/ai_resul_info.dart';
@@ -7,13 +9,14 @@ import 'package:flutter/material.dart';
 import 'package:get/get.dart';
 
 class AIResultPanel extends StatefulWidget {
-  const AIResultPanel(this.aiDetectedObject, this.diagnosisOrgan, {Key? key})
-      : super(key: key);
   final List<AIDetectedObject> aiDetectedObject;
 
   /// ai部位
   final DiagnosisOrganEnum diagnosisOrgan;
 
+  const AIResultPanel(this.aiDetectedObject, this.diagnosisOrgan, {Key? key})
+      : super(key: key);
+
   @override
   State<AIResultPanel> createState() => _AIResultPanelState();
 }
@@ -21,6 +24,11 @@ class AIResultPanel extends StatefulWidget {
 class _AIResultPanelState extends State<AIResultPanel> {
   late AIDetectedObject aiDetectedObjectItem;
   final aiPatintController = Get.find<AiPatintController>();
+  MeasureDataController get measureData => Get.find<MeasureDataController>();
+
+  Map<String, String> get parasmeters => {
+        "remedicalCode": measureData.measureImageData.remedicalCode ?? "",
+      };
   @override
   Widget build(BuildContext context) {
     return Row(
@@ -86,6 +94,23 @@ class _AIResultPanelState extends State<AIResultPanel> {
                         ResultInfo(
                           widget.aiDetectedObject,
                         ),
+                        if (aiDetectedObjectItem.descriptions?.isNotEmpty ??
+                            false)
+                          InkWell(
+                            onTap: () {
+                              Get.dialog(
+                                const AiResultModifierDialog(),
+                                arguments: parasmeters,
+                              );
+                            },
+                            child: const Text(
+                              "修改AI结果",
+                              style: TextStyle(
+                                color: Colors.white,
+                                decoration: TextDecoration.underline,
+                              ),
+                            ),
+                          ),
                         const SizedBox(
                           height: 10,
                         ),

+ 3 - 2
pubspec.yaml

@@ -39,6 +39,7 @@ dependencies:
   url_launcher: 6.1.5
   flutter_easyloading: 3.0.3
   audio_video_progress_bar: 0.11.0
+  sleek_circular_slider: 2.0.1
   vid:
     git:
       url: http://git.ius.plus:88/Project-Wing/flutter_vid
@@ -81,7 +82,7 @@ dependency_overrides:
   fis_ui:
     git:
       url: http://git.ius.plus/Project-Wing/fis_lib_ui.git
-      ref: 9dbe5e5
+      ref: 0907b06
     #path: ../fis_lib_ui
   vid:
     git:
@@ -99,7 +100,7 @@ dependency_overrides:
   fis_jsonrpc:
     git:
       url: http://git.ius.plus:88/Project-Wing/fis_lib_jsonrpc.git
-      ref: 08adb53
+      ref: e5d045a
   fis_lib_business_components:
     git:
       url: http://git.ius.plus/Project-Wing/fis_lib_business_components.git