跳转至

Flutter 多模态识图

使用多模态模型让 AI 理解图片内容,支持 OCR 文字提取、图像描述、图片问答等。


功能预览

多模态识图页面

左侧导航选择 👁️ 多模态识图 → 上传图片 → 输入查询指令 → 获取 AI 分析结果


目录


基础概念

多模态模型支持在 messages 中同时发送文本和图像:

{
  'model': 'gpt-4o-mini',
  'messages': [
    {
      'role': 'user',
      'content': [
        {'type': 'text', 'text': '这张图片里有什么?'},
        {
          'type': 'image_url',
          'image_url': {
            'url': 'https://example.com/image.jpg'
          }
        }
      ]
    }
  ]
}

支持两种图像输入方式:

  1. 图像 URL:公网可访问的图片地址
  2. Base64 Data URIdata:image/png;base64,xxxxxx(本地图片)

常用多模态模型:

  • gpt-4o-mini / gpt-4o - OpenAI 视觉模型
  • doubao-1-5-vision-pro-32k-250115 - 中文视觉更强
  • qwen2.5vl-72b-instruct - 开源多模态模型

本地图片转 Base64

import 'dart:io';
import 'dart:convert';
import 'package:image_picker/image_picker.dart';

Future<String?> pickAndEncode() async {
  final picker = ImagePicker();
  final XFile? file = await picker.pickImage(
    source: ImageSource.gallery,
    imageQuality: 80,
  );
  if (file == null) return null;

  final bytes = await file.readAsBytes();
  final base64Str = base64.encode(bytes);
  final mimeType = file.path.toLowerCase().endsWith('.png')
      ? 'image/png'
      : 'image/jpeg';
  return 'data:$mimeType;base64,$base64Str';
}

使用图像 URL

Future<String> describeImage(String imageUrl) async {
  final response = await http.post(
    Uri.parse('${dotenv.env['APINEXUS_BASE_URL']!}/chat/completions'),
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ${dotenv.env['APINEXUS_API_KEY']!}',
    },
    body: json.encode({
      'model': 'gpt-4o-mini',
      'messages': [
        {
          'role': 'user',
          'content': [
            {'type': 'text', 'text': '请用中文详细描述这张图片的内容。'},
            {
              'type': 'image_url',
              'image_url': {'url': imageUrl}
            }
          ]
        }
      ],
    }),
  );
  final data = json.decode(response.body);
  return data['choices'][0]['message']['content'];
}

图片描述 / OCR

使用 Base64 本地图片做 OCR 文字提取:

Future<String> extractTextFromImage(String base64Image) async {
  final response = await http.post(
    Uri.parse('${dotenv.env['APINEXUS_BASE_URL']!}/chat/completions'),
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ${dotenv.env['APINEXUS_API_KEY']!}',
    },
    body: json.encode({
      'model': 'gpt-4o-mini',
      'messages': [
        {
          'role': 'user',
          'content': [
            {
              'type': 'text',
              'text': '请提取图片中的所有文字,按段落输出。如果图片中没有文字,请回复"未检测到文字"。'
            },
            {
              'type': 'image_url',
              'image_url': {'url': base64Image}
            }
          ]
        }
      ],
    }),
  );
  final data = json.decode(response.body);
  return data['choices'][0]['message']['content'];
}

图片问答

根据图片内容回答用户问题:

Future<String> askAboutImage(String question, String base64Image) async {
  final response = await http.post(
    Uri.parse('${dotenv.env['APINEXUS_BASE_URL']!}/chat/completions'),
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ${dotenv.env['APINEXUS_API_KEY']!}',
    },
    body: json.encode({
      'model': 'gpt-4o-mini',
      'messages': [
        {
          'role': 'user',
          'content': [
            {'type': 'text', 'text': question},
            {
              'type': 'image_url',
              'image_url': {'url': base64Image}
            }
          ]
        }
      ],
    }),
  );
  final data = json.decode(response.body);
  return data['choices'][0]['message']['content'];
}

完整示例

在 Flutter 应用中集成图片理解:

class VisionPage extends StatefulWidget {
  const VisionPage({super.key});

  @override
  State<VisionPage> createState() => _VisionPageState();
}

class _VisionPageState extends State<VisionPage> {
  String? _imagePath;
  String? _base64Image;
  final _questionController = TextEditingController(
    text: '请描述这张图片的主要内容',
  );
  String _result = '';
  bool _loading = false;

  Future<void> _pickImage() async {
    final picker = ImagePicker();
    final file = await picker.pickImage(
      source: ImageSource.gallery,
      imageQuality: 80,
    );
    if (file == null) return;

    final bytes = await file.readAsBytes();
    final b64 = base64.encode(bytes);
    final mimeType = file.path.toLowerCase().endsWith('.png')
        ? 'image/png'
        : 'image/jpeg';
    setState(() {
      _imagePath = file.path;
      _base64Image = 'data:$mimeType;base64,$b64';
    });
  }

  Future<void> _ask() async {
    if (_base64Image == null || _loading) return;
    setState(() => _loading = true);
    try {
      final reply = await askAboutImage(
        _questionController.text,
        _base64Image!,
      );
      setState(() => _result = reply);
    } catch (e) {
      setState(() => _result = '请求失败: $e');
    } finally {
      setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('多模态识图')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            ElevatedButton(
              onPressed: _pickImage,
              child: const Text('选择图片'),
            ),
            if (_imagePath != null) ...[
              const SizedBox(height: 12),
              Image.file(
                File(_imagePath!),
                height: 200,
                fit: BoxFit.cover,
              ),
            ],
            const SizedBox(height: 16),
            TextField(
              controller: _questionController,
              decoration: const InputDecoration(
                labelText: '你想了解这张图片的什么内容?',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 8),
            ElevatedButton(
              onPressed: _base64Image == null || _loading ? null : _ask,
              child: Text(_loading ? '分析中...' : '分析图片'),
            ),
            const SizedBox(height: 16),
            if (_result.isNotEmpty)
              Container(
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: Colors.blue[50],
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Text(_result),
              ),
          ],
        ),
      ),
    );
  }
}

继续学习