image_processor.dart 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import 'dart:io';
  2. import 'package:image/image.dart' as img;
  3. import 'package:sino_med_cloud/core/utils/logger.dart';
  4. import 'media_exception.dart';
  5. /// 图片处理封装(压缩 / 裁剪 / 医疗脱敏)
  6. class ImageProcessor {
  7. /// 默认压缩宽度(医疗场景推荐)
  8. static const int defaultMaxWidth = 1080;
  9. /// 默认压缩高度(医疗场景推荐)
  10. static const int defaultMaxHeight = 1920;
  11. /// 默认压缩质量(85% 平衡质量和体积)
  12. static const int defaultQuality = 85;
  13. /// 压缩图片
  14. ///
  15. /// 医疗场景:降低上传体积、去EXIF、图片标准化
  16. ///
  17. /// - [file] 原始图片文件
  18. /// - [maxWidth] 最大宽度,默认 1080px
  19. /// - [maxHeight] 最大高度,默认 1920px
  20. /// - [quality] 压缩质量(1-100),默认 85
  21. ///
  22. /// 返回压缩后的文件(覆盖原文件)
  23. static Future<File> compress(
  24. File file, {
  25. int maxWidth = defaultMaxWidth,
  26. int? maxHeight,
  27. int quality = defaultQuality,
  28. }) async {
  29. try {
  30. if (!await file.exists()) {
  31. throw ImageProcessingException('图片文件不存在: ${file.path}');
  32. }
  33. final bytes = await file.readAsBytes();
  34. if (bytes.isEmpty) {
  35. throw ImageProcessingException('图片文件为空: ${file.path}');
  36. }
  37. final image = img.decodeImage(bytes);
  38. if (image == null) {
  39. throw ImageProcessingException('无法解析图片: ${file.path}');
  40. }
  41. // 使用默认最大高度(如果未指定)
  42. final effectiveMaxHeight = maxHeight ?? defaultMaxHeight;
  43. // 计算是否需要缩放
  44. final needsResize = image.width > maxWidth || image.height > effectiveMaxHeight;
  45. File? resultFile;
  46. if (!needsResize) {
  47. // 如果图片尺寸都在限制内,只进行质量压缩
  48. final compressed = img.encodeJpg(image, quality: quality);
  49. resultFile = File(file.path)..writeAsBytesSync(compressed);
  50. } else {
  51. // 计算缩放比例,保持宽高比
  52. final widthRatio = image.width / maxWidth;
  53. final heightRatio = image.height / effectiveMaxHeight;
  54. final ratio = widthRatio > heightRatio ? widthRatio : heightRatio;
  55. // 计算新尺寸
  56. final newWidth = (image.width / ratio).round();
  57. final newHeight = (image.height / ratio).round();
  58. // 先缩放再压缩
  59. final resized = img.copyResize(
  60. image,
  61. width: newWidth,
  62. height: newHeight,
  63. maintainAspect: false, // 我们已经计算好了尺寸
  64. );
  65. final compressed = img.encodeJpg(resized, quality: quality);
  66. resultFile = File(file.path)..writeAsBytesSync(compressed);
  67. }
  68. AppLogger.d('图片压缩完成: ${file.path}, 原始大小: ${bytes.length} bytes, 压缩后: ${resultFile.lengthSync()} bytes');
  69. return resultFile;
  70. } catch (e) {
  71. if (e is ImageProcessingException) {
  72. rethrow;
  73. }
  74. AppLogger.e('图片压缩失败: ${file.path}', e);
  75. throw ImageProcessingException(
  76. '图片压缩失败: ${e.toString()}',
  77. originalError: e,
  78. );
  79. }
  80. }
  81. /// 获取图片信息
  82. ///
  83. /// 返回图片的宽度、高度和文件大小
  84. static Future<Map<String, dynamic>> getImageInfo(File file) async {
  85. try {
  86. if (!await file.exists()) {
  87. throw ImageProcessingException('图片文件不存在: ${file.path}');
  88. }
  89. final bytes = await file.readAsBytes();
  90. final image = img.decodeImage(bytes);
  91. if (image == null) {
  92. throw ImageProcessingException('无法解析图片: ${file.path}');
  93. }
  94. return {
  95. 'width': image.width,
  96. 'height': image.height,
  97. 'size': bytes.length,
  98. 'format': image.format.toString(),
  99. };
  100. } catch (e) {
  101. if (e is ImageProcessingException) {
  102. rethrow;
  103. }
  104. AppLogger.e('获取图片信息失败: ${file.path}', e);
  105. throw ImageProcessingException(
  106. '获取图片信息失败: ${e.toString()}',
  107. originalError: e,
  108. );
  109. }
  110. }
  111. /// 医疗脱敏处理(去除 EXIF 信息)
  112. ///
  113. /// 移除图片中的 EXIF 元数据,保护患者隐私
  114. ///
  115. /// - [file] 原始图片文件
  116. ///
  117. /// 返回脱敏后的文件(覆盖原文件)
  118. static Future<File> removeExif(File file) async {
  119. try {
  120. if (!await file.exists()) {
  121. throw ImageProcessingException('图片文件不存在: ${file.path}');
  122. }
  123. final bytes = await file.readAsBytes();
  124. final image = img.decodeImage(bytes);
  125. if (image == null) {
  126. throw ImageProcessingException('无法解析图片: ${file.path}');
  127. }
  128. // 重新编码图片会移除 EXIF 信息
  129. final cleaned = img.encodeJpg(image, quality: 100);
  130. final cleanedFile = File(file.path)..writeAsBytesSync(cleaned);
  131. AppLogger.d('图片脱敏完成: ${file.path}');
  132. return cleanedFile;
  133. } catch (e) {
  134. if (e is ImageProcessingException) {
  135. rethrow;
  136. }
  137. AppLogger.e('图片脱敏失败: ${file.path}', e);
  138. throw ImageProcessingException(
  139. '图片脱敏失败: ${e.toString()}',
  140. originalError: e,
  141. );
  142. }
  143. }
  144. }