项目实训(九)| 金融知识问答:多模态输入之图片输入
·
一、需求背景
在金融领域,用户经常需要从图表、报表、新闻截图等图片中获取信息。原系统仅支持文字输入,用户需要先将图片中的内容手动打字输入,这种方式不仅效率低下,还容易出现信息遗漏。因此,图片输入功能成为提升用户体验的关键需求。
功能目标:
- 支持从相册选择图片或直接拍照上传
- 自动解析图片内容,结合金融知识库进行问答
- 跨平台支持:Android、iOS、Web
二、功能设计
2.1 使用场景分析
| 场景 | 示例 | 用户需求 |
|---|---|---|
| 图表解析 | 用户上传股票K线图 | 解析图表数据,解答相关问题 |
| 报表识别 | 上传财务报表截图 | 提取关键财务指标 |
| 新闻配图 | 金融新闻中的截图 | 根据图片内容回答相关问题 |
| 数据对比 | 多张图片对比分析 | 综合分析多图信息 |
2.2 技术选型
图片选择库:image_picker
选择理由:
- 官方维护,稳定性好
- 支持 Android、iOS、Web 全平台
- API 简洁,易于集成
- 支持相册选择和相机拍照两种方式
三、开发实现
3.1 前端开发
3.1.1 依赖配置
# pubspec.yaml
dependencies:
image_picker: ^1.0.4
permission_handler: ^11.3.1
3.1.2 核心代码实现
状态定义:
final ImagePicker _imagePicker = ImagePicker();
String? _selectedImagePath;
Uint8List? _selectedImageBytes; // Web平台使用
图片选择方法:
Future<void> _pickImage(ImageSource source) async {
// 1. 调用系统图片选择器
final XFile? image = await _imagePicker.pickImage(source: source);
if (image != null) {
// 2. 根据平台处理图片数据
if (kIsWeb) {
// Web平台:读取图片字节数据
final bytes = await image.readAsBytes();
setState(() {
_selectedImagePath = image.name; // 保存文件名用于显示
_selectedImageBytes = bytes;
});
} else {
// 移动端/桌面端:保存文件路径
setState(() {
_selectedImagePath = image.path;
_selectedImageBytes = null;
});
}
}
}
UI 交互实现:
// 图片选择按钮
PopupMenuButton<ImageSource>(
icon: const Icon(Icons.add_photo_alternate),
onSelected: _pickImage,
itemBuilder: (context) => [
PopupMenuItem(
value: ImageSource.gallery,
child: Row(
children: [
Icon(Icons.image, size: 18),
SizedBox(width: 8),
Text('从相册选择'),
],
),
),
PopupMenuItem(
value: ImageSource.camera,
child: Row(
children: [
Icon(Icons.camera_alt, size: 18),
SizedBox(width: 8),
Text('拍照'),
],
),
),
],
)
发送问答:
Future<void> _ask() async {
if (_questionCtrl.text.isEmpty && _selectedImagePath == null) {
return;
}
// 调用 Provider 发送问答请求
await context.read<KnowledgeQaProvider>().askQuestion(
_questionCtrl.text.isEmpty ? '[图片]' : _questionCtrl.text,
userLevel: _userLevel,
imagePath: kIsWeb ? null : _selectedImagePath,
imageBytes: kIsWeb ? _selectedImageBytes : null,
imageFileName: _selectedImagePath,
);
}
3.2 后端开发
3.2.1 API 接口设计
采用 FastAPI 的文件上传机制,支持 multipart/form-data 格式:
@router.post("/ask/multimodal", summary="多模态金融知识问答")
async def ask_question_multimodal(
question: str = Form(...),
user_level: str = Form('入门'),
image: UploadFile = File(None),
audio: UploadFile = File(None),
user_id: str = Depends(get_current_user_id),
):
image_path = None
try:
# ============ 图片处理 ============
if image:
# 创建上传目录
upload_root = Path(settings.UPLOAD_DIR)
upload_root.mkdir(parents=True, exist_ok=True)
# 生成唯一文件名,避免冲突
image_path = upload_root / f"mm_{uuid.uuid4().hex}_{image.filename}"
# 保存文件
with image_path.open("wb") as out:
shutil.copyfileobj(image.file, out)
logger.info(f"[知识问答] 上传图片: {image.filename} -> {image_path}")
# ============ 调用问答服务 ============
result = await rag_service.answer_question_with_multimodal(
question=question,
user_level=user_level,
image_path=str(image_path) if image_path else None,
)
return ResponseBase(data=result)
finally:
# ============ 清理临时文件 ============
if image_path and image_path.exists():
image_path.unlink(missing_ok=True)
3.2.2 多模态问答服务
class RAGService:
async def answer_question_with_multimodal(
self,
question: str,
user_level: str = "入门",
image_path: Optional[str] = None,
audio_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
多模态问答:支持图片和音频输入的问答。
Args:
question: 用户问题
user_level: 用户水平
image_path: 图片路径
audio_path: 音频路径
"""
normalized_level = self._normalize_user_level(user_level)
media_info = []
if image_path:
media_info.append(f"图片路径: {image_path}")
if audio_path:
media_info.append(f"音频路径: {audio_path}")
logger.info(
f"[RAGService] 多模态问答: '{question[:50]}' "
f"level={normalized_level} media={len(media_info)}"
)
# 调用 LLM 进行问答(图片会在内部进行 OCR + 图像理解)
# ...
3.3 流式输出支持
为了提升用户体验,支持流式输出,让用户能边生成边看到内容:
@router.post("/ask/multimodal/stream", summary="多模态金融知识问答(流式输出)")
async def ask_question_multimodal_stream(
question: str = Form(...),
user_level: str = Form('入门'),
image: UploadFile = File(None),
audio: UploadFile = File(None),
user_id: str = Depends(get_current_user_id),
):
# ... 图片处理逻辑同上 ...
async def event_generator():
async for event in rag_service.stream_answer_question_with_multimodal(
question=question,
user_level=user_level,
image_path=str(image_path) if image_path else None,
audio_path=str(audio_path) if audio_path else None,
):
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream"
)
四、平台兼容性处理
4.1 Web 平台特殊处理
Web 平台的文件处理与移动端不同,需要特别注意:
| 特性 | 移动端 | Web 平台 |
|---|---|---|
| 文件引用 | 文件路径 | Uint8List 字节数据 |
| 文件名 | 从路径提取 | 直接获取 |
| 上传方式 | multipart/form-data | Base64 编码 |
Web 端上传服务:
Future<Map<String, dynamic>> _uploadImage({
required String question,
required String userLevel,
required Uint8List imageBytes,
required String fileName,
}) async {
final uri = Uri.parse('$baseUrl/ask/multimodal');
final request = http.MultipartRequest('POST', uri);
// 添加表单字段
request.fields['question'] = question;
request.fields['user_level'] = userLevel;
// 添加图片文件(Web平台使用bytes)
request.files.add(http.MultipartFile.fromBytes(
'image',
imageBytes,
filename: fileName,
));
final streamedResponse = await request.send();
final response = await http.Response.fromStream(streamedResponse);
return jsonDecode(response.body);
}
4.2 权限处理
不同平台需要不同的权限声明:
Android (AndroidManifest.xml):
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
iOS (Info.plist):
<key>NSCameraUsageDescription</key>
<string>需要相机权限来拍照上传</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要相册权限来选择图片</string>
Web:无需配置权限,由浏览器自动处理。
五、开发问题与解决方案
5.1 图片文件过大的问题
问题:用户上传大图片导致上传慢、响应时间长。
解决方案:
// 在选择图片时限制大小
final XFile? image = await _imagePicker.pickImage(
source: source,
maxWidth: 1920, // 最大宽度
maxHeight: 1080, // 最大高度
imageQuality: 85, // 图片质量 0-100
);
5.2 图片格式兼容性问题
问题:某些特殊格式(如 HEIC)在不同平台显示不一致。
解决方案:
- 后端统一转换为标准格式(如 JPEG)处理
- 前端限制可选格式:
image_picker默认支持 JPEG、PNG 等常见格式
5.3 临时文件清理
问题:上传的图片文件如果不清理会占用服务器存储空间。
解决方案:采用 try-finally 模式确保临时文件被清理:
try:
if image:
# 保存文件
with image_path.open("wb") as out:
shutil.copyfileobj(image.file, out)
# 处理业务逻辑
result = await rag_service.answer_question_with_multimodal(...)
return ResponseBase(data=result)
finally:
# 无论成功失败都清理临时文件
if image_path and image_path.exists():
image_path.unlink(missing_ok=True)
六、软件工程实践
6.1 DRY 原则(Don’t Repeat Yourself)
在图片处理逻辑中,我们复用了多模态问答的服务层,语音和图片共用一套处理流程:
6.2 关注点分离
| 层级 | 职责 |
|---|---|
| UI 层 | 用户交互、图片选择、状态展示 |
| Provider 层 | 业务逻辑编排、状态管理 |
| Service 层 | API 调用、数据转换 |
| 后端 API | 请求处理、文件管理 |
| 后端 Service | 业务逻辑、LLM 调用 |
6.3 用户体验优化
- 即时反馈:选择图片后立即显示预览
- 流式输出:边生成边展示,减少等待焦虑
- 错误处理:上传失败时显示友好提示
- 进度提示:大文件上传时显示加载动画
七、总结
本次图片输入功能的开发,让我对跨平台移动开发有了更深入的理解:
- 平台差异需要针对性处理:Web 和移动端的文件处理方式完全不同,需要在架构层面就考虑兼容性问题
- 用户体验是核心竞争力:好的 UX 设计(如流式输出、即时预览)能显著提升产品竞争力
- 安全性不容忽视:文件上传功能需要做好文件类型验证、大小限制等安全措施
- 资源清理很重要:临时文件必须及时清理,避免占用服务器存储
图片输入功能的上线,使得用户可以更便捷地获取金融知识,无论是分析股票图表还是理解财务报告,都能得到智能化的问答支持。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)