Browse Source

feat(图片翻译): 实现图片翻译功能并添加结果预览

添加图片翻译API调用功能,包括:
1. 新增图片翻译相关模型类(Point2D, ImageTextBlock, ImageTranslationResult)
2. 实现火山引擎图片翻译API调用逻辑
3. 在界面中添加翻译结果图片预览区域
4. 优化翻译服务初始化逻辑避免重复初始化
5. 适配暗黑模式下的UI显示
weicu
lxm 12 months ago
parent
commit
cade5f6717
  1. 132
      lib/data/services/volcano_translation_service.dart
  2. 87
      lib/modules/picture_translation/controllers/picture_translation_controller.dart
  3. 61
      lib/modules/picture_translation/models/picture_translation_models.dart
  4. 69
      lib/modules/picture_translation/views/picture_translation_view.dart
  5. 81
      lib/modules/text_translation/controllers/text_translation_controller.dart
  6. 3
      lib/modules/text_translation/views/text_translation_view.dart

132
lib/data/services/volcano_translation_service.dart

@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import '../../data/models/appconfig.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart' as http;
@ -8,16 +9,17 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get/get.dart';
import '../../core/utils/logger.dart';
import 'language_manager.dart';
import '../../modules/picture_translation/models/picture_translation_models.dart';
//// 火山引擎机器翻译服务
class VolcanoTranslationService extends GetxService {
// API 配置
late String _baseUrl;
final String _endpoint = '/'; // 修改为根路径
late final String _accessKey;
late final String _secretKey;
late final String _region;
late final String _service;
late String _accessKey;
late String _secretKey;
late String _region;
late String _service;
// 初始化标志
bool _isInitialized = false;
@ -27,9 +29,22 @@ class VolcanoTranslationService extends GetxService {
// final GetStorage _storage = GetStorage();
// 构造函数
VolcanoTranslationService();
@override
Future<void> onInit() async {
await initialize();
super.onInit();
}
// 对外暴露初始化状态,便于控制器判断
bool get isInitialized => _isInitialized;
// 初始化服务
Future<bool> initialize() async {
// 已初始化则直接返回,避免重复初始化
if (_isInitialized) {
Logger.info('翻译服务已初始化,跳过重复初始化');
return true;
}
try {
// 优先使用后端 AppConfig,其次回退到 .env
final bool appConfigReady = AppConfig.isInitialized();
@ -63,7 +78,8 @@ class VolcanoTranslationService extends GetxService {
}
_isInitialized = true;
Logger.info('lxm---Volcano Translation Service initialized成功');
Logger.info(
'lxm---Volcano Translation Service initialized成功, accessKey: $_accessKey, region: $_region, service: $_service,secretKey: $_secretKey');
return true;
} catch (e) {
Logger.error('lxm---Volcano Translation Service initialized失败,$e');
@ -271,4 +287,110 @@ class VolcanoTranslationService extends GetxService {
return null;
}
}
/// 图片翻译(整图识别+回贴翻译)
Future<ImageTranslationResult?> translateImage({
required Uint8List imageBytes,
required String targetLanguageCode, // ASR代码,如 zh-CN / en-US
}) async {
if (!_isInitialized) {
Logger.error('图片翻译服务未初始化');
return null;
}
try {
// 目标语言短代码(例如 zh / en)
String targetCode = 'zh';
final targetShortCode =
_languageManager.getShortCodeByAsrCode(targetLanguageCode);
if (targetShortCode != null && targetShortCode.isNotEmpty) {
targetCode = targetShortCode;
}
// 将图片二进制转为base64字符串(不带前缀)
final imgBase64 = base64.encode(imageBytes);
// 构建请求体(根据官方文档)
final requestBody = {
'Image': imgBase64,
'TargetLanguage': targetCode,
};
// 查询参数(官方示例仅包含 Action/Version;为保持与文本翻译一致,保留 Region/Service)
final queryParams = {
'Action': 'TranslateImage',
'Version': '2020-07-01',
'Region': _region,
'Service': _service,
};
// 生成签名与请求头
final headers = _generateSignature('POST', requestBody, queryParams);
// 拼接请求URL
final queryString = Uri(queryParameters: queryParams).query;
final url = '$_baseUrl/?$queryString';
final response = await http.post(
Uri.parse(url),
headers: headers,
body: json.encode(requestBody),
);
if (response.statusCode == 200) {
final responseData = json.decode(response.body);
// 错误元数据检测
if (responseData is Map &&
responseData['ResponseMetadata'] is Map &&
(responseData['ResponseMetadata']['Error'] != null)) {
final err = responseData['ResponseMetadata']['Error'];
Logger.error('图片翻译错误: code=${err['Code']}, message=${err['Message']}');
return null;
}
// 解析回贴图片
Uint8List? translatedImage;
if (responseData['Image'] is String) {
try {
translatedImage = base64.decode(responseData['Image']);
} catch (_) {
translatedImage = null;
}
}
// 解析文本块
final List<ImageTextBlock> blocks = [];
if (responseData['TextBlocks'] is List) {
for (final item in (responseData['TextBlocks'] as List)) {
if (item is Map<String, dynamic>) {
blocks.add(ImageTextBlock.fromJson(item));
} else if (item is Map) {
blocks.add(ImageTextBlock.fromJson(
item.map((k, v) => MapEntry(k.toString(), v))));
}
}
}
// 提取一个整体检测语种(若存在文本块)
String? detectedLang;
if (blocks.isNotEmpty && blocks.first.detectedLanguage.isNotEmpty) {
detectedLang = blocks.first.detectedLanguage;
}
return ImageTranslationResult(
translatedImageBytes: translatedImage,
blocks: blocks,
detectedLanguage: detectedLang,
);
}
Logger.error(
'图片翻译HTTP错误: status=${response.statusCode}, body=${response.body}');
return null;
} catch (e) {
Logger.error('图片翻译请求异常: $e');
return null;
}
}
}

87
lib/modules/picture_translation/controllers/picture_translation_controller.dart

@ -30,6 +30,7 @@ class PictureTranslationController extends GetxController {
final targetLanguageChineseName = '英语'.obs;
final sourceText = ''.obs;
final translatedText = ''.obs;
final translatedImageBytes = Rx<Uint8List?>(null); // 回贴后的图片二进制
final isProcessing = false.obs;
final pickedImageFile = Rx<File?>(null);
final currentStep = ProcessingStep(
@ -215,27 +216,11 @@ class PictureTranslationController extends GetxController {
file = await _preprocessImage(file);
pickedImageFile.value = file;
_updateStep('识别文本', '正在分析图片中的文本...', ProcessingStatus.recognizing,
progress: 0.3);
// 直接调用图片翻译API(包含识别+回贴)
_updateStep('翻译图片', '正在调用图片翻译服务...', ProcessingStatus.translating,
progress: 0.6);
// 文本识别
// final inputImage = InputImage.fromFile(file);
// final RecognizedText recognizedText =
// await _textRecognizer.processImage(inputImage);
// sourceText.value = recognizedText.text.trim();
if (sourceText.value.isEmpty) {
_updateStep('识别完成', '未识别到任何文本', ProcessingStatus.idle);
translatedText.value = '';
return;
}
_updateStep('翻译文本', '正在翻译识别的文本...', ProcessingStatus.translating,
progress: 0.7);
// 执行翻译
await _performTranslation();
await _performImageTranslation(file);
// 保存历史记录
if (sourceText.value.isNotEmpty && translatedText.value.isNotEmpty) {
@ -293,22 +278,53 @@ class PictureTranslationController extends GetxController {
}
}
/// 执行翻译
Future<void> _performTranslation() async {
final sourceCode = languageManager
.getAsrCodeByChineseName(sourceLanguageChineseName.value) ??
'zh-CN';
final targetCode = languageManager
.getAsrCodeByChineseName(targetLanguageChineseName.value) ??
'en-US';
final result = await _translationService.translateText(
text: sourceText.value,
sourceLanguageCode: sourceCode,
targetLanguageCode: targetCode,
);
/// 执行图片翻译(调用火山引擎图片翻译API)
Future<void> _performImageTranslation(File imageFile) async {
try {
final bytes = await imageFile.readAsBytes();
translatedText.value = result ?? '';
// 图片大小限制:4MB(文档要求)
const int maxSize = 4 * 1024 * 1024;
if (bytes.lengthInBytes > maxSize) {
_updateStep('处理失败', '图片大小超过4MB限制', ProcessingStatus.error);
_showErrorDialog('处理失败', '图片大小超过4MB限制,请降低质量或尺寸后重试');
return;
}
final targetCode = languageManager
.getAsrCodeByChineseName(targetLanguageChineseName.value) ??
'zh-CN';
final result = await _translationService.translateImage(
imageBytes: bytes,
targetLanguageCode: targetCode,
);
if (result == null) {
_updateStep('处理失败', '图片翻译接口返回空结果', ProcessingStatus.error);
_showErrorDialog('处理失败', '图片翻译失败,可能的原因:网络异常或接口限流,请稍后重试');
return;
}
// 更新回贴图片
translatedImageBytes.value = result.translatedImageBytes != null
? Uint8List.fromList(result.translatedImageBytes!)
: null;
// 将文本块汇总为识别文本与翻译文本,便于展示与复制
if (result.blocks.isNotEmpty) {
sourceText.value = result.blocks.map((b) => b.text).join('\n');
translatedText.value =
result.blocks.map((b) => b.translation).join('\n');
} else {
sourceText.value = '';
translatedText.value = '';
}
} catch (e) {
Logger.error('PictureTranslation', '图片翻译调用失败: $e');
_updateStep('处理失败', e.toString(), ProcessingStatus.error);
_showErrorDialog('处理失败', '图片翻译接口调用异常,请检查网络后重试');
}
}
/// 保存翻译结果
@ -395,6 +411,7 @@ class PictureTranslationController extends GetxController {
void reset() {
sourceText.value = '';
translatedText.value = '';
translatedImageBytes.value = null;
pickedImageFile.value = null;
_updateStep('等待操作', '请选择图片开始翻译', ProcessingStatus.idle);
}

61
lib/modules/picture_translation/models/picture_translation_models.dart

@ -132,3 +132,64 @@ class ProcessingStep {
);
}
}
/// 图片翻译返回的坐标点模型(用于文本块四个点)
class Point2D {
final int x;
final int y;
Point2D({required this.x, required this.y});
/// 从JSON创建
factory Point2D.fromJson(Map<String, dynamic> json) {
return Point2D(
x: (json['X'] ?? 0) as int,
y: (json['Y'] ?? 0) as int,
);
}
}
/// 图片翻译返回的文本块模型
class ImageTextBlock {
final List<Point2D> points; // 文本块四点坐标
final String detectedLanguage; // 检测到的语种
final String text; // 识别内容
final String translation; // 翻译结果
final List<int>? foreColor; // 文字平均色 RGBA
final List<int>? backColor; // 背景平均色 RGBA
ImageTextBlock({
required this.points,
required this.detectedLanguage,
required this.text,
required this.translation,
this.foreColor,
this.backColor,
});
/// 从JSON创建
factory ImageTextBlock.fromJson(Map<String, dynamic> json) {
final List<dynamic> pts = (json['Points'] ?? []) as List<dynamic>;
return ImageTextBlock(
points: pts.map((e) => Point2D.fromJson(e as Map<String, dynamic>)).toList(),
detectedLanguage: (json['DetectedLanguage'] ?? '') as String,
text: (json['Text'] ?? '') as String,
translation: (json['Translation'] ?? '') as String,
foreColor: (json['ForeColor'] as List?)?.map((e) => e as int).toList(),
backColor: (json['BackColor'] as List?)?.map((e) => e as int).toList(),
);
}
}
/// 图片翻译结果模型(包含回贴后的图片和文本块列表)
class ImageTranslationResult {
final List<int>? translatedImageBytes; // 回贴后的图片二进制(Uint8List)
final List<ImageTextBlock> blocks; // 文本块列表
final String? detectedLanguage; // 可选:整体检测到的语种
ImageTranslationResult({
required this.translatedImageBytes,
required this.blocks,
this.detectedLanguage,
});
}

69
lib/modules/picture_translation/views/picture_translation_view.dart

@ -15,9 +15,10 @@ class PictureTranslationView extends GetView<PictureTranslationController> {
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: Colors.grey[50],
appBar: _buildAppBar(),
appBar: _buildAppBar(isDarkMode),
body: SafeArea(
child: Obx(() {
return CustomScrollView(
@ -53,20 +54,27 @@ class PictureTranslationView extends GetView<PictureTranslationController> {
}
/// 构建应用栏
PreferredSizeWidget _buildAppBar() {
PreferredSizeWidget _buildAppBar(bool isDarkMode) {
return AppBar(
leading: IconButton(
icon: Icon(
Icons.arrow_back_ios,
size: 20.w,
color: Colors.black87,
color: isDarkMode ? Colors.white : Colors.black87,
),
onPressed: () => Get.back(),
),
centerTitle: true,
title: const Text('图片翻译'),
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
title: Text(
'pictureTranslation'.tr,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
backgroundColor: isDarkMode ? Colors.black : Colors.white,
foregroundColor: isDarkMode ? Colors.white : Colors.black87,
elevation: 0,
actions: [
IconButton(
@ -492,6 +500,55 @@ class PictureTranslationView extends GetView<PictureTranslationController> {
controller.targetLanguageChineseName.value,
Colors.green,
),
if (controller.translatedImageBytes.value != null) ...[
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Text(
'翻译后的图片预览',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
SizedBox(height: 8.h),
Container(
height: 200.h,
margin: EdgeInsets.symmetric(horizontal: 16.w)
.copyWith(bottom: 16.h),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: Colors.grey[300]!),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8.r),
child: Image.memory(
controller.translatedImageBytes.value!,
fit: BoxFit.cover,
width: double.infinity,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline,
size: 48.sp, color: Colors.red[400]),
SizedBox(height: 8.h),
Text(
'翻译图片加载失败',
style: TextStyle(
fontSize: 14.sp, color: Colors.red[600]),
),
],
),
);
},
),
),
),
],
],
),
);

81
lib/modules/text_translation/controllers/text_translation_controller.dart

@ -10,7 +10,8 @@ import '../../../core/utils/logger.dart';
/// 文本翻译控制器
class TextTranslationController extends GetxController {
// 依赖服务
final VolcanoTranslationService _translationService = Get.find<VolcanoTranslationService>();
final VolcanoTranslationService _translationService =
Get.find<VolcanoTranslationService>();
final LanguageManager _languageManager = Get.find<LanguageManager>();
final GetStorage _storage = GetStorage();
@ -47,6 +48,12 @@ class TextTranslationController extends GetxController {
/// 初始化翻译服务
Future<void> _initializeTranslationService() async {
try {
// 已初始化则跳过
if (_translationService.isInitialized) {
Logger.info('翻译服务已初始化');
return;
}
// final initialized =
final initialized = await _translationService.initialize();
if (!initialized) {
Logger.error('翻译服务初始化失败');
@ -74,22 +81,28 @@ class TextTranslationController extends GetxController {
/// 加载保存的语言设置
void _loadSavedLanguages() {
try {
final savedSourceLanguage = _storage.read('text_translation_source_language');
final savedTargetLanguage = _storage.read('text_translation_target_language');
final savedSourceLanguage =
_storage.read('text_translation_source_language');
final savedTargetLanguage =
_storage.read('text_translation_target_language');
if (savedSourceLanguage != null && supportedLanguages.contains(savedSourceLanguage)) {
if (savedSourceLanguage != null &&
supportedLanguages.contains(savedSourceLanguage)) {
sourceLanguageChineseName.value = savedSourceLanguage;
// 同时更新语言代码
final asrCode = _languageManager.getAsrCodeByChineseName(savedSourceLanguage);
final asrCode =
_languageManager.getAsrCodeByChineseName(savedSourceLanguage);
if (asrCode != null) {
sourceLanguageCode.value = asrCode;
}
}
if (savedTargetLanguage != null && supportedLanguages.contains(savedTargetLanguage)) {
if (savedTargetLanguage != null &&
supportedLanguages.contains(savedTargetLanguage)) {
targetLanguageChineseName.value = savedTargetLanguage;
// 同时更新语言代码
final asrCode = _languageManager.getAsrCodeByChineseName(savedTargetLanguage);
final asrCode =
_languageManager.getAsrCodeByChineseName(savedTargetLanguage);
if (asrCode != null) {
targetLanguageCode.value = asrCode;
}
@ -102,8 +115,10 @@ class TextTranslationController extends GetxController {
/// 保存语言设置
void _saveLanguageSettings() {
try {
_storage.write('text_translation_source_language', sourceLanguageChineseName.value);
_storage.write('text_translation_target_language', targetLanguageChineseName.value);
_storage.write(
'text_translation_source_language', sourceLanguageChineseName.value);
_storage.write(
'text_translation_target_language', targetLanguageChineseName.value);
} catch (e) {
Logger.error('保存语言设置失败: $e');
}
@ -173,20 +188,21 @@ class TextTranslationController extends GetxController {
/// 执行翻译
Future<void> translateText() async {
final text = textController.text.trim();
// 验证输入
if (text.isEmpty) {
Get.snackbar(
'error'.tr,
'pleaseInputText'.tr,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.orange.withOpacity(0.9),
colorText: Colors.white,
);
// Get.snackbar(
// 'error'.tr,
// 'pleaseInputText'.tr,
// snackPosition: SnackPosition.TOP,
// backgroundColor: Colors.orange.withOpacity(0.9),
// colorText: Colors.white,
// );
return;
}
if (text.length > 1000) {
if (text.length > 5000) {
// 5000 字符限制
Get.snackbar(
'error'.tr,
'textTooLong'.tr,
@ -196,24 +212,29 @@ class TextTranslationController extends GetxController {
);
return;
}
// 检查源语言和目标语言是否相同
if (sourceLanguageChineseName.value == targetLanguageChineseName.value) {
translatedText.value = text;
return;
}
isTranslating.value = true;
translatedText.value = '';
try {
Logger.info('开始翻译: $text');
Logger.info('源语言: ${sourceLanguageChineseName.value} -> ${sourceLanguageCode.value}');
Logger.info('目标语言: ${targetLanguageChineseName.value} -> ${targetLanguageCode.value}');
Logger.info(
'源语言: ${sourceLanguageChineseName.value} -> ${sourceLanguageCode.value}');
Logger.info(
'目标语言: ${targetLanguageChineseName.value} -> ${targetLanguageCode.value}');
// 确保翻译服务已初始化
Logger.info('确保翻译服务已初始化...');
final initialized = await _translationService.initialize();
bool initialized = _translationService.isInitialized;
if (!initialized) {
initialized = await _translationService.initialize();
}
Logger.info('翻译服务初始化结果: $initialized');
if (!initialized) {
throw Exception('翻译服务初始化失败');
@ -289,7 +310,9 @@ class TextTranslationController extends GetxController {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
isSourceLanguage ? 'sourceLanguage'.tr : 'targetLanguage'.tr,
isSourceLanguage
? 'sourceLanguage'.tr
: 'targetLanguage'.tr,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@ -311,10 +334,12 @@ class TextTranslationController extends GetxController {
final isSelected = isSourceLanguage
? language == sourceLanguageChineseName.value
: language == targetLanguageChineseName.value;
return ListTile(
title: Text(language),
trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null,
trailing: isSelected
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () {
if (isSourceLanguage) {
setSourceLanguage(language);

3
lib/modules/text_translation/views/text_translation_view.dart

@ -55,12 +55,13 @@ class TextTranslationView extends GetView<TextTranslationController> {
),
onPressed: () => Get.back(),
),
centerTitle: true,
title: Text(
'textTranslation'.tr,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
actions: [

Loading…
Cancel
Save