前端 + AI 进阶 Day6: 图片标注与 AI 视觉分析(完整版)

前端 + AI 进阶学习路线|Week 1-2:流式体验优化

Day 6:图片标注与 AI 视觉分析

学习时间:2025年12月30日(星期二)
关键词:Canvas 标注、画框、圈选、多模态 AI、LLaVA、Base64、视觉理解


📁 项目文件结构

day06-image-annotation/
├── src/
│   ├── components/
│   │   ├── ImageUpload.jsx          # 复用 Day 5 的上传组件
│   │   └── ImageAnnotator.jsx       # 新增:Canvas 图片标注组件
│   ├── lib/
│   │   └── visualAIClient.js        # Ollama LLaVA 流式客户端(模拟)
│   └── App.jsx                      # 主应用集成(含标注 + 提问)
├── package.json                     # 需添加 proxy 配置(如对接真实 Ollama)
└── public/

💡 本日核心:前端标注 + 视觉 AI 语义理解,为多模态交互闭环打下基础


🎯 今日学习目标

  1. 在 Canvas 上实现 矩形框选(Bounding Box)自由圈选(Lasso)
  2. 支持在图片上添加 文字标注
  3. 将标注区域信息与图片一起发送给多模态 AI(如 LLaVA)
  4. 构建“上传 → 标注 → 提问 → AI 视觉回答”完整流程

💡 为什么需要前端图片标注?

用户不仅想问“这张图是什么”,更想问:

  • 红框里的按钮 是干什么的?”
  • 圈出的部分 为什么报错?”
  • 这张设计稿 的字体是什么?”

✅ 前端标注 = 精准视觉上下文 → 提升 AI 回答准确性
📌 标注信息(坐标、区域、标签)需与图片一同传给 AI


📚 核心技术栈

功能 技术
图片绘制与交互 <canvas> + 鼠标事件(mousedown/mousemove/mouseup
标注数据结构 `{ type: 'rect'
图片转 Base64 canvas.toDataURL('image/jpeg', 0.8)(Day 6 模拟,Day 7 真实)
多模态 AI 输入 图片(Base64) + 文本提示(含标注描述)

⚠️ 注意:Ollama 的 LLaVA 模型 支持 Base64 图片输入(需 ollama run llava


🔧 动手实践:构建可标注的图片分析组件

步骤 1:创建项目并安装依赖

npx create-react-app day06-image-annotation
cd day06-image-annotation
# 本日无需新 npm 包,纯原生 Canvas 实现

💡 如需对接真实 Ollama,后续可添加 @microsoft/fetch-event-source

步骤 2:复用 Day 5 的上传组件

// src/components/ImageUpload.jsx
import { useState, useRef, useCallback, useEffect } from 'react';

const ImageUpload = ({ onFileSelect }) => {
  const [previewUrl, setPreviewUrl] = useState(null);
  const [isDragging, setIsDragging] = useState(false);
  const fileInputRef = useRef(null);

  useEffect(() => {
    return () => {
      if (previewurl) URL.revokeObjectURL(previewUrl);
    };
  }, [previewUrl]);

  const handleFile = useCallback((file) => {
    if (!file || !file.type.startsWith('image/')) {
      alert('请上传图片文件(PNG/JPG/GIF)');
      return;
    }
    const url = URL.createObjectURL(file);
    setPreviewUrl(url);
    onFileSelect?.(file);
  }, [onFileSelect]);

  const handleSelectClick = () => fileInputRef.current?.click();
  const handleFileChange = (e) => handleFile(e.target.files?.[0]);
  const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
  const handleDragLeave = () => setIsDragging(false);
  const handleDrop = (e) => { e.preventDefault(); setIsDragging(false); handleFile(e.dataTransfer.files?.[0]); };

  return (
    <div
      style={{
        padding: '20px',
        border: '2px dashed #ccc',
        borderRadius: '8px',
        textAlign: 'center',
        backgroundColor: isDragging ? '#f0f9ff' : '#fafafa',
        cursor: 'pointer',
      }}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
      onClick={handleSelectClick}
      tabIndex={0}
    >
      <input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" style={{ display: 'none' }} />
      {previewUrl ? (
        <img src={previewUrl} alt="预览" style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '4px' }} />
      ) : (
        <div>📸 拖拽图片到此处,或点击选择</div>
      )}
    </div>
  );
};

export default ImageUpload;

步骤 3:创建图片标注组件

// src/components/ImageAnnotator.jsx
import { useState, useRef, useEffect } from 'react';

const ImageAnnotator = ({ imageFile, onAnnotated }) => {
  const canvasRef = useRef(null);
  const [isDrawing, setIsDrawing] = useState(false);
  const [annotations, setAnnotations] = useState([]);
  const [mode, setMode] = useState('rect'); // 'rect' | 'lasso'
  const [tempPoints, setTempPoints] = useState([]);
  const imgRef = useRef(null);

  // 加载图片到内存
  useEffect(() => {
    if (!imageFile) return;
    const img = new Image();
    img.onload = () => {
      imgRef.current = img;
      drawImageAndAnnotations(img, []);
    };
    img.src = URL.createObjectURL(imageFile);
    return () => URL.revokeObjectURL(img.src);
  }, [imageFile]);

  const drawImageAndAnnotations = (img, anns) => {
    const canvas = canvasRef.current;
    if (!canvas || !img) return;
    const ctx = canvas.getContext('2d');
    const maxWidth = 800;
    const scale = Math.min(maxWidth / img.width, 1);
    const w = img.width * scale;
    const h = img.height * scale;

    canvas.width = w;
    canvas.height = h;
    ctx.clearRect(0, 0, w, h);
    ctx.drawImage(img, 0, 0, w, h);

    // 绘制已有标注
    anns.forEach((ann) => {
      ctx.strokeStyle = ann.type === 'rect' ? '#1890ff' : '#f5222d';
      ctx.lineWidth = 2;
      ctx.beginPath();

      if (ann.type === 'rect' && ann.points.length === 2) {
        const [start, end] = ann.points;
        ctx.rect(start.x, start.y, end.x - start.x, end.y - start.y);
      } else if (ann.type === 'lasso' && ann.points.length > 1) {
        ctx.moveTo(ann.points[0].x, ann.points[0].y);
        ann.points.slice(1).forEach((p) => ctx.lineTo(p.x, p.y));
        ctx.closePath();
      }
      ctx.stroke();

      // 绘制标签
      if (ann.label) {
        ctx.fillStyle = '#1890ff';
        ctx.font = '14px sans-serif';
        ctx.fillText(ann.label, ann.points[0].x + 5, ann.points[0].y - 5);
      }
    });
  };

  const getMousePos = (e) => {
    const canvas = canvasRef.current;
    const rect = canvas.getBoundingClientRect();
    const scaleX = canvas.width / rect.width;
    const scaleY = canvas.height / rect.height;
    return {
      x: (e.clientX - rect.left) * scaleX,
      y: (e.clientY - rect.top) * scaleY,
    };
  };

  const handleMouseDown = (e) => {
    if (!imgRef.current) return;
    setIsDrawing(true);
    const pos = getMousePos(e);
    if (mode === 'rect') {
      setTempPoints([pos, pos]);
    } else if (mode === 'lasso') {
      setTempPoints([pos]);
    }
  };

  const handleMouseMove = (e) => {
    if (!isDrawing || !imgRef.current) return;
    const pos = getMousePos(e);
    if (mode === 'rect') {
      setTempPoints(([start]) => [start, pos]);
    } else if (mode === 'lasso') {
      setTempPoints((prev) => [...prev, pos]);
    }
  };

  const handleMouseUp = () => {
    if (!isDrawing) return;
    setIsDrawing(false);
    const label = prompt('请输入标注标签(如“错误弹窗”):', '');
    if (tempPoints.length > 0) {
      const newAnn = {
        id: Date.now(),
        type: mode,
        points: [...tempPoints],
        label: label || '未命名',
      };
      const updated = [...annotations, newAnn];
      setAnnotations(updated);
      drawImageAndAnnotations(imgRef.current, updated);
      setTempPoints([]);
      onAnnotated?.(updated);
    }
  };

  // 重绘临时图形
  useEffect(() => {
    if (isDrawing && imgRef.current) {
      const allAnns = [...annotations, { type: mode, points: tempPoints }];
      drawImageAndAnnotations(imgRef.current, allAnns);
    }
  }, [tempPoints, isDrawing]);

  return (
    <div style={{ textAlign: 'center' }}>
      <div style={{ marginBottom: '12px' }}>
        <button
          onClick={() => setMode('rect')}
          style={{
            marginRight: '8px',
            backgroundColor: mode === 'rect' ? '#1890ff' : '#f0f0f0',
            color: mode === 'rect' ? 'white' : 'black',
            border: '1px solid #ccc',
            padding: '4px 8px',
            borderRadius: '4px',
          }}
        >
          🖱️ 矩形框选
        </button>
        <button
          onClick={() => setMode('lasso')}
          style={{
            backgroundColor: mode === 'lasso' ? '#f5222d' : '#f0f0f0',
            color: mode === 'lasso' ? 'white' : 'black',
            border: '1px solid #ccc',
            padding: '4px 8px',
            borderRadius: '4px',
          }}
        >
          ✏️ 自由圈选
        </button>
      </div>

      <canvas
        ref={canvasRef}
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
        onMouseLeave={handleMouseUp}
        style={{
          border: '1px solid #ddd',
          borderRadius: '4px',
          cursor: mode === 'rect' ? 'crosshair' : 'cell',
          maxWidth: '100%',
          backgroundColor: '#fafafa',
        }}
      />

      {annotations.length > 0 && (
        <div style={{ marginTop: '12px', textAlign: 'left' }}>
          <strong>已标注区域 ({annotations.length} 个):</strong>
          <ul>
            {annotations.map((ann) => (
              <li key={ann.id}>
                {ann.type === 'rect' ? '矩形' : '圈选'}: “{ann.label}”
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
};

export default ImageAnnotator;

步骤 4:创建视觉 AI 客户端(模拟流式)

// src/lib/visualAIClient.js
/**
 * 模拟 LLaVA 视觉分析(真实版见 Day 6 扩展)
 * 返回预设响应以演示流程
 */
export const streamVisualAnalysis = async ({ prompt, onToken, onComplete }) => {
  const mockResponse = `根据你的标注,我分析如下:

- 图中包含一个用户界面截图
- **“错误弹窗”** 区域显示了一个红色警告图标和“网络连接失败”文本
- **“提交按钮”** 是一个蓝色矩形按钮,带有白色“提交”文字

建议:检查网络设置或重试操作。`;

  let index = 0;
  const interval = setInterval(() => {
    if (index < mockResponse.length) {
      onToken(mockResponse[index]);
      index++;
    } else {
      clearInterval(interval);
      onComplete();
    }
  }, 30);
};

步骤 5:在 App 中集成完整流程

// src/App.jsx
import { useState } from 'react';
import ImageUpload from './components/ImageUpload';
import ImageAnnotator from './components/ImageAnnotator';
import { streamVisualAnalysis } from './lib/visualAIClient';

function App() {
  const [imageFile, setImageFile] = useState(null);
  const [annotations, setAnnotations] = useState([]);
  const [aiResponse, setAiResponse] = useState('');
  const [isAnalyzing, setIsAnalyzing] = useState(false);
  const [userQuestion, setUserQuestion] = useState('');

  const handleAnnotated = (anns) => {
    setAnnotations(anns);
  };

  const sendToVisualAI = async () => {
    if (!imageFile || !userQuestion.trim()) {
      alert('请先上传图片并输入问题');
      return;
    }

    // 构建带标注的提示词
    let fullPrompt = userQuestion;
    if (annotations.length > 0) {
      fullPrompt += '\n\n用户特别标注了以下区域:\n';
      annotations.forEach((ann, i) => {
        fullPrompt += `${i + 1}. ${ann.label}(${ann.type === 'rect' ? '矩形区域' : '圈选区域'})\n`;
      });
    }

    setAiResponse('');
    setIsAnalyzing(true);

    await streamVisualAnalysis({
      prompt: fullPrompt,
      onToken: (token) => {
        setAiResponse(prev => prev + token);
      },
      onComplete: () => {
        setIsAnalyzing(false);
      }
    });
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'Inter, -apple-system, sans-serif', maxWidth: '800px', margin: '0 auto' }}>
      <h1 style={{ textAlign: 'center', fontSize: '24px', fontWeight: '700', color: '#1d1d1f' }}>
        多模态分析:上传 + 标注 + AI 视觉理解
      </h1>
      <p style={{ textAlign: 'center', color: '#666', marginBottom: '20px' }}>
        支持矩形框选与自由圈选,精准提问
      </p>

      {!imageFile ? (
        <ImageUpload onFileSelect={setImageFile} />
      ) : (
        <div>
          <ImageAnnotator imageFile={imageFile} onAnnotated={handleAnnotated} />
          
          <div style={{ marginTop: '20px' }}>
            <input
              type="text"
              value={userQuestion}
              onChange={(e) => setUserQuestion(e.target.value)}
              placeholder="请输入你的问题(如:红框里是什么?)"
              style={{
                width: '100%',
                padding: '10px',
                fontSize: '16px',
                borderRadius: '4px',
                border: '1px solid #ccc',
                marginBottom: '10px',
              }}
            />
            <button
              onClick={sendToVisualAI}
              disabled={isAnalyzing}
              style={{
                padding: '10px 20px',
                backgroundColor: isAnalyzing ? '#b0b0b0' : '#52c41a',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                fontSize: '16px',
                cursor: isAnalyzing ? 'not-allowed' : 'pointer',
              }}
            >
              {isAnalyzing ? '🤖 分析中...' : '发送给视觉 AI'}
            </button>
            
            {aiResponse && (
              <div
                style={{
                  marginTop: '16px',
                  padding: '16px',
                  backgroundColor: '#f9f9f9',
                  borderRadius: '8px',
                  whiteSpace: 'pre-wrap',
                  lineHeight: 1.6,
                  border: '1px solid #eee',
                }}
              >
                {aiResponse}
              </div>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

export default App;

✅ 效果验证

  • ✅ 上传图片 → 在 Canvas 上绘制
  • ✅ 切换“矩形框选” → 拖拽画框 → 输入标签 → 保存标注
  • ✅ 切换“自由圈选” → 鼠标绘制任意形状 → 输入标签
  • ✅ 标注列表实时更新
  • ✅ 输入问题(如“红框是什么?”)→ 点击“发送给视觉 AI” → 查看流式回答

🤔 思考与延伸

  1. 真实 LLaVA 调用:如何将 Base64 图片发送给 Ollama?

    // Ollama LLaVA 请求体
    {
      model: "llava",
      prompt: "What's in this image?",
      images: ["<base64_string>"]
    }
    
  2. 性能优化:大图转 Base64 慢?
    → 可压缩图片(Canvas 缩放后 toDataURL

  3. 标注导出:能否导出为 COCO 或 YOLO 格式?
    → 需实现坐标归一化与格式转换

💡 扩展建议:在 visualAIClient.js 中替换为真实 fetchEventSource 调用(参考 Day 4),即可对接本地 Ollama LLaVA。


📅 明日预告

Day 7:批量上传与进度管理

  • 支持 多文件拖拽/选择
  • 显示 上传进度条(模拟或真实)
  • 构建“批量图片分析”工作流

✍️ 小结

今天,我们赋予了用户“指哪问哪”的能力!通过前端标注,AI 不再盲目猜测,而是聚焦用户关心的区域。视觉 + 语言 + 交互,三位一体的多模态体验,正在成型。

💬 实践提示:真实 LLaVA 调用需先运行 ollama run llava,并确保图片 Base64 不超过模型输入限制。欢迎分享你的标注交互设计!

posted @ 2025-12-29 14:18  XiaoZhengTou  阅读(20)  评论(0)    收藏  举报