文档扫描工具开发:高拍仪硬件集成与图像处理流水线

33_文档扫描工具开发:高拍仪硬件集成与图像处理流水线.md

作者 : WeClaw 开发团队
日期 : 2026-03-25
版本 : v1.0
标签: 文档扫描、高拍仪、GLM-4.6V、图像处理、OCR、教育科技


📖 摘要

本文深入剖析高拍仪文档扫描工具从 0 到 1 的完整开发过程。针对教育场景中的试卷批改、作业辅导等核心需求,我们展示了如何集成高拍仪硬件、使用 GLM-4.6V 视觉大模型进行智能题目识别、构建 SQLite 缓存防重复机制、实现批量处理与增量更新。文章涵盖硬件选型、图像预处理、多模态 API 调用、数据库设计等全链路技术实践。

核心收获

  • 📷 掌握高拍仪硬件集成与图像捕获技术
  • 🧠 理解多模态大模型(GLM-4.6V)在教育场景的应用
  • 💾 学会文件指纹缓存与数据库持久化设计
  • 🔄 获得批量处理与增量更新的完整实现方案
  • 📊 掌握 LaTeX 公式规范化输出技巧

🎯 需求背景:为什么需要文档扫描工具?

真实用户场景

在 WeClaw 的用户调研中,我们发现以下高频需求:

  1. 家长辅导作业 📚

    • 孩子完成作业后,家长需要快速检查对错
    • 遇到不会的题目,需要详细解答和知识点讲解
    • 手动输入题目到搜题软件效率低下
  2. 教师批改试卷 ✏️

    • 每次考试后需要批改大量试卷
    • 统计每道题的得分率、错误类型
    • 分析学生的知识薄弱点
  3. 学生自主复习 📝

    • 整理错题本,定期回顾
    • 搜索相似题目进行针对性练习
    • 需要详细的解题步骤而非简单答案

现有方案的局限

方案 优点 缺点 用户体验
作业帮/小猿 题库大、响应快 需手动拍照上传、答案简略 ⭐⭐⭐
扫描仪 + OCR 清晰度高 无法理解题目语义 ⭐⭐
人工录入 准确率高 效率极低(1 题/分钟)

我们的解决方案

高拍仪 + GLM-4.6V 多模态大模型

复制代码
高拍仪拍摄 (1 秒)
    ↓
图像预处理 (去噪/增强/校正)
    ↓
GLM-4.6V 视觉理解 (10-30 秒)
    ↓
结构化输出 (Markdown + JSON)
    ↓
SQLite 持久化存储
    ↓
✅ 完整的题目解析 + 解答步骤 + 知识点标注

核心优势

  • 一键扫描:高拍仪硬件快速拍摄
  • 智能理解:大模型识别题目语义,非简单 OCR
  • 详细解答:包含解题步骤、知识点、易错点
  • 缓存机制:相同题目秒级返回,无需重复解析
  • 批量处理:一次扫描整页试卷,自动分割题目

🏗️ 整体架构设计

系统架构图

复制代码
┌─────────────────────────────────────────────────────┐
│                  UI 层(主窗口)                     │
│  - 扫描按钮触发                                     │
│  - 进度显示                                         │
│  - 结果预览                                         │
└───────────────────┬─────────────────────────────────┘
                    │
        ┌───────────▼───────────┐
        │ DocumentScannerTool   │
        │  - scan_file          │
        │  - scan_folder        │
        │  - query_history      │
        └───────────┬───────────┘
                    │
        ┌───────────▼───────────┐
        │   业务逻辑层           │
        │  - 文件哈希计算        │
        │  - 缓存检查            │
        │  - 图像预处理          │
        │  - GLM-4.6V 调用       │
        └───────────┬───────────┘
                    │
        ┌───────────▼───────────┐
        │   数据持久化层         │
        │  - SQLite 数据库       │
        │  - Markdown 导出       │
        │  - JSON 结构化数据     │
        └───────────────────────┘

核心模块划分

模块 职责 关键技术
硬件集成 高拍仪控制、图像捕获 USB 通信、SDK 调用
图像预处理 去噪、增强、透视校正 OpenCV、Pillow
题目识别 多模态视觉理解 GLM-4.6V API
缓存管理 文件指纹、防重复解析 SHA256、SQLite
结果生成 Markdown/JSON 输出 Jinja2 模板
批量处理 文件夹扫描、并发控制 asyncio.gather

📷 核心模块一:高拍仪硬件集成

硬件选型标准

选择高拍仪时,我们考虑以下关键指标:

指标 要求 原因
分辨率 ≥8MP 确保文字清晰可辨
对焦方式 自动对焦 适应不同厚度纸张
补光灯 内置 LED 避免阴影干扰
接口 USB 3.0 高速数据传输
SDK 支持 Windows/Mac 跨平台兼容性
价格 ¥200-500 性价比优先

最终选择:成者(CZUR)ET18 / 良田(ELOAM)S500L

软件控制流程

python 复制代码
class HighSpeedCamera:
    """高拍仪相机控制类。"""
    
    def __init__(self, device_index: int = 0):
        """初始化相机。
        
        Args:
            device_index: 设备索引(多相机时使用)
        """
        self.device_index = device_index
        self.camera = None
        self.is_initialized = False
    
    async def initialize(self) -> bool:
        """初始化相机连接。
        
        Returns:
            True 表示成功,False 表示失败
        """
        try:
            import cv2
            
            # 打开相机
            self.camera = cv2.VideoCapture(self.device_index)
            
            if not self.camera.isOpened():
                logger.error("无法打开相机设备 %d", self.device_index)
                return False
            
            # 设置参数
            self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, 3264)  # 8MP
            self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 2448)
            self.camera.set(cv2.CAP_PROP_AUTOFOCUS, 1)  # 自动对焦
            self.camera.set(cv2.CAP_PROP_AUTO_EXPOSURE, 1)  # 自动曝光
            
            self.is_initialized = True
            logger.info("相机初始化完成")
            return True
            
        except Exception as e:
            logger.error("相机初始化失败:%s", e)
            return False
    
    async def capture_image(self, output_path: str) -> bool:
        """拍摄照片并保存。
        
        Args:
            output_path: 输出文件路径
        
        Returns:
            True 表示成功
        """
        if not self.is_initialized:
            logger.error("相机未初始化")
            return False
        
        try:
            # 预热(丢弃前几帧不稳定的图像)
            for _ in range(5):
                self.camera.read()
            
            # 捕获图像
            ret, frame = self.camera.read()
            
            if not ret:
                logger.error("图像捕获失败")
                return False
            
            # 保存为高质量 JPEG
            save_path = Path(output_path)
            save_path.parent.mkdir(parents=True, exist_ok=True)
            
            # 使用最高质量保存
            cv2.imwrite(
                str(save_path),
                frame,
                [cv2.IMWRITE_JPEG_QUALITY, 95]
            )
            
            logger.info("图像已保存:%s (%.2f MB)", 
                       save_path.name, save_path.stat().st_size / 1024 / 1024)
            return True
            
        except Exception as e:
            logger.error("保存图像失败:%s", e)
            return False
    
    def close(self):
        """关闭相机释放资源。"""
        if self.camera and self.is_initialized:
            self.camera.release()
            self.is_initialized = False
            logger.info("相机已关闭")

图像预处理流水线

python 复制代码
def preprocess_image(image_path: str, enhance: bool = True) -> np.ndarray:
    """图像预处理流水线。
    
    处理步骤:
    1. 读取图像
    2. 去噪(双边滤波)
    3. 透视校正(如果检测到文档边缘)
    4. 对比度增强(可选)
    5. 锐化处理
    
    Args:
        image_path: 输入图像路径
        enhance: 是否增强
    
    Returns:
        预处理后的图像(numpy 数组)
    """
    import cv2
    import numpy as np
    
    # 1. 读取图像
    img = cv2.imread(image_path)
    if img is None:
        raise ValueError(f"无法读取图像:{image_path}")
    
    # 2. 去噪(双边滤波,保留边缘)
    denoised = cv2.bilateralFilter(img, 9, 75, 75)
    
    # 3. 透视校正(检测文档边缘并矫正)
    corrected = perspective_correction(denoised)
    
    # 4. 对比度增强(可选)
    if enhance:
        enhanced = enhance_contrast(corrected)
    else:
        enhanced = corrected
    
    # 5. 锐化
    sharpened = sharpen_image(enhanced)
    
    logger.info("图像预处理完成:%dx%d", sharpened.shape[1], sharpened.shape[0])
    return sharpened


def perspective_correction(img: np.ndarray) -> np.ndarray:
    """透视校正:检测文档边缘并矫正。
    
    使用 Canny 边缘检测 + 轮廓查找,找到最大的四边形区域,
    然后进行透视变换将其"拉平"。
    """
    import cv2
    import numpy as np
    
    # 转换为灰度图
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    
    # 高斯模糊
    blurred = cv2.GaussianBlur(gray, (5, 5), 0)
    
    # Canny 边缘检测
    edges = cv2.Canny(blurred, 75, 200)
    
    # 查找轮廓
    contours, _ = cv2.findContours(
        edges.copy(),
        cv2.RETR_EXTERNAL,
        cv2.CHAIN_APPROX_SIMPLE
    )
    
    # 找到最大的四边形轮廓
    doc_contour = None
    max_area = 0
    
    for contour in contours:
        # 近似轮廓
        peri = cv2.arcLength(contour, True)
        approx = cv2.approxPolyDP(contour, 0.02 * peri, True)
        
        # 如果是四边形且面积最大
        if len(approx) == 4:
            area = cv2.contourArea(contour)
            if area > max_area:
                max_area = area
                doc_contour = approx
    
    # 如果没有找到四边形,返回原图
    if doc_contour is None:
        logger.warning("未检测到文档边缘,跳过透视校正")
        return img
    
    # 获取四点坐标并按顺序排列
    pts = order_points(doc_contour.reshape(4, 2))
    
    # 计算新图像的宽度高度
    width_a = np.linalg.norm(pts[0] - pts[1])
    width_b = np.linalg.norm(pts[2] - pts[3])
    max_width = max(int(width_a), int(width_b))
    
    height_a = np.linalg.norm(pts[0] - pts[3])
    height_b = np.linalg.norm(pts[1] - pts[2])
    max_height = max(int(height_a), int(height_b))
    
    # 目标点坐标
    dst_pts = np.array([
        [0, 0],
        [max_width - 1, 0],
        [max_width - 1, max_height - 1],
        [0, max_height - 1]
    ], dtype="float32")
    
    # 透视变换
    M = cv2.getPerspectiveTransform(pts, dst_pts)
    warped = cv2.warpPerspective(img, M, (max_width, max_height))
    
    logger.info("透视校正完成:%dx%d", max_width, max_height)
    return warped


def enhance_contrast(img: np.ndarray) -> np.ndarray:
    """对比度增强:CLAHE 算法。
    
    使用限制对比度自适应直方图均衡化(CLAHE),
    避免过度增强导致的噪声放大。
    """
    import cv2
    
    # 转换到 LAB 色彩空间
    lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
    
    # 提取 L 通道(亮度)
    l, a, b = cv2.split(lab)
    
    # CLAHE 增强
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
    enhanced_l = clahe.apply(l)
    
    # 合并通道
    enhanced_lab = cv2.merge([enhanced_l, a, b])
    
    # 转回 BGR
    enhanced = cv2.cvtColor(enhanced_lab, cv2.COLOR_LAB2BGR)
    
    return enhanced


def sharpen_image(img: np.ndarray) -> np.ndarray:
    """锐化处理:增强文字边缘。
    
    使用拉普拉斯算子进行锐化。
    """
    import cv2
    import numpy as np
    
    # 创建锐化核
    kernel = np.array([[-1, -1, -1],
                       [-1,  9, -1],
                       [-1, -1, -1]])
    
    # 应用卷积
    sharpened = cv2.filter2D(img, -1, kernel)
    
    # 限制像素值范围
    sharpened = np.clip(sharpened, 0, 255).astype(np.uint8)
    
    return sharpened


def order_points(pts: np.ndarray) -> np.ndarray:
    """按顺时针顺序排列四点:左上、右上、右下、左下。"""
    import numpy as np
    
    # 按 x 坐标排序
    x_sorted = pts[np.argsort(pts[:, 0]), :]
    
    # 最左边的两个点是左上和左下
    left_most = x_sorted[:2, :]
    # 最右边的两个点是右上和右下
    right_most = x_sorted[2:, :]
    
    # 按 y 坐标区分左上/左下
    diff = np.diff(left_most, axis=1)
    (tl, bl) = left_most[np.argsort(diff), :]
    
    # 按 y 坐标区分右上/右下
    diff = np.diff(right_most, axis=1)
    (tr, br) = right_most[np.argsort(diff), :]
    
    return np.array([tl, tr, br, bl], dtype="float32")

🧠 核心模块二:GLM-4.6V 多模态识别

为什么选择 GLM-4.6V?

模型 视觉理解 文字识别 公式支持 成本 延迟
GLM-4.6V ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ¥¥ ~5s
GPT-4V ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ¥¥¥ ~8s
OCR 引擎 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ ¥ ~1s
传统 CV ⭐⭐⭐ ⭐⭐⭐ ¥ ~0.5s

GLM-4.6V 优势

  1. 语义理解:不仅识别文字,更理解题目意图
  2. 公式支持:完美支持 LaTeX 数学公式
  3. 结构化输出:可直接生成 JSON 格式
  4. 中文优化:针对中文教育场景深度优化

Prompt 工程设计

python 复制代码
def build_vision_prompt(subject: str, grade_level: str) -> str:
    """构建视觉理解的 Prompt。
    
    Args:
        subject: 科目(数学/物理/化学等)
        grade_level: 年级(小学/初中/高中)
    
    Returns:
        完整的 Prompt 文本
    """
    prompt = f"""你是一位经验丰富的{grade_level}{subject}教师,正在批改学生的作业/试卷。

请仔细分析图片中的每一道题目,完成以下任务:

1. **题目识别**:
   - 准确识别题目内容(包括公式、图表)
   - 判断题目类型(选择题/填空题/解答题)
   - 提取题目的分值(如果有)

2. **详细解答**:
   - 给出完整的解题步骤
   - 标注涉及的知识点
   - 指出易错点和注意事项
   - 提供至少一种解题方法

3. **评分建议**:
   - 如果是已作答的题目,给出评分建议
   - 指出学生的错误类型(计算错误/概念不清/审题不严)

4. **拓展练习**:
   - 推荐 1-2 道相似的练习题
   - 提供相关的学习资源链接

**输出格式要求**:
请严格按照以下 JSON 格式输出(不要包含任何额外说明):

```json
{{
  "subject": "{subject}",
  "grade_level": "{grade_level}",
  "total_score": 100,
  "problems": [
    {{
      "problem_id": 1,
      "type": "选择题",
      "content": "题目的完整内容,公式用 LaTeX 表示",
      "score": 5,
      "knowledge_points": ["知识点 1", "知识点 2"],
      "solution": {{
        "steps": ["步骤 1", "步骤 2", "..."],
        "final_answer": "最终答案",
        "alternative_methods": ["其他解法..."]
      }},
      "common_mistakes": ["易错点 1", "易错点 2"],
      "difficulty": "中等"
    }}
  ],
  "summary": {{
    "total_problems": 10,
    "key_knowledge_areas": ["知识领域 1", "知识领域 2"],
    "learning_suggestions": ["学习建议 1", "建议 2"]
  }}
}}

注意事项

  1. 所有数学公式必须用 LaTeX 格式(如 x2+y2=z2x^2 + y^2 = z^2x2+y2=z2)

  2. 保持解答的专业性和准确性

  3. 语言简洁明了,适合{grade_level}学生理解

  4. 如果图片中有手写内容,尽量辨认但不确定的地方要标注

    """

    return prompt

    API 调用封装

    python 复制代码
    async def call_glm_vision(
        image_base64: str,
        subject: str,
        grade_level: str,
        timeout: int = 60
    ) -> dict:
        """调用 GLM-4.6V 进行视觉理解。
        
        Args:
            image_base64: Base64 编码的图像数据
            subject: 科目
            grade_level: 年级
            timeout: 超时时间(秒)
        
        Returns:
            解析后的 JSON 数据
        
        Raises:
            ValueError: API 调用失败
        """
        import httpx
        import base64
        import json
        
        api_key = os.getenv("GLM_API_KEY") or os.getenv("ZHIPUAI_API_KEY")
        if not api_key:
            raise ValueError("GLM_API_KEY 环境变量未配置")
        
        # 构建请求
        headers = {
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json"
        }
        
        prompt = build_vision_prompt(subject, grade_level)
        
        payload = {
            "model": "glm-4v",
            "messages": [
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": f"data:image/jpeg;base64,{image_base64}"
                            }
                        },
                        {
                            "type": "text",
                            "text": prompt
                        }
                    ]
                }
            ],
            "temperature": 0.3,  # 较低温度,确保输出稳定
            "top_p": 0.9,
            "max_tokens": 4096
        }
        
        try:
            async with httpx.AsyncClient(timeout=timeout) as client:
                response = await client.post(
                    "https://open.bigmodel.cn/api/paas/v4/chat/completions",
                    headers=headers,
                    json=payload
                )
                response.raise_for_status()
                
                result = response.json()
                
                # 提取回复内容
                content = result["choices"][0]["message"]["content"]
                
                # 尝试解析 JSON
                json_data = extract_json_from_response(content)
                
                if not json_data:
                    logger.warning("未能从响应中提取 JSON")
                    return {"raw_text": content}
                
                return json_data
                
        except httpx.TimeoutException:
            logger.error("API 请求超时")
            raise ValueError("API 请求超时,请稍后重试")
        except httpx.HTTPStatusError as e:
            logger.error("HTTP 错误:%d - %s", e.response.status_code, e.response.text)
            raise ValueError(f"API 调用失败:{e.response.status_code}")
        except Exception as e:
            logger.error("未知错误:%s", e)
            raise ValueError(f"调用失败:{e}")
    
    
    def extract_json_from_response(text: str) -> dict | None:
        """从模型回复中提取 JSON 数据。
        
        模型可能返回:
        1. 纯 JSON
        2. Markdown 代码块包裹的 JSON
        3. JSON 前后有额外说明
        
        Args:
            text: 模型回复文本
        
        Returns:
            解析后的 JSON 字典,失败返回 None
        """
        import json
        import re
        
        # 尝试直接解析
        try:
            return json.loads(text)
        except json.JSONDecodeError:
            pass
        
        # 尝试提取 Markdown 代码块中的 JSON
        pattern = r'```(?:json)?\s*(\{.*?\})\s*```'
        match = re.search(pattern, text, re.DOTALL)
        
        if match:
            json_str = match.group(1)
            try:
                return json.loads(json_str)
            except json.JSONDecodeError:
                pass
        
        # 尝试找到第一个 { 和最后一个 }
        start = text.find('{')
        end = text.rfind('}') + 1
        
        if start != -1 and end > start:
            json_str = text[start:end]
            try:
                return json.loads(json_str)
            except json.JSONDecodeError:
                pass
        
        logger.warning("无法提取 JSON 数据")
        return None

💾 核心模块三:缓存与数据库设计

文件指纹缓存机制

python 复制代码
async def get_file_hash(file_path: Path) -> str:
    """计算文件的 SHA256 哈希值(文件指纹)。
    
    为什么要用 SHA256?
    1. 唯一性:不同内容的文件几乎不可能有相同的哈希
    2. 快速:计算速度快,适合大文件
    3. 安全:不可逆,保护原始文件信息
    
    Args:
        file_path: 文件路径
    
    Returns:
        64 位十六进制字符串
    """
    sha256 = hashlib.sha256()
    
    with open(file_path, "rb") as f:
        # 分块读取,避免大文件占用过多内存
        for chunk in iter(lambda: f.read(8192), b""):
            sha256.update(chunk)
    
    return sha256.hexdigest()


async def check_cache(file_hash: str) -> dict | None:
    """检查缓存中是否已有该文件的解析结果。
    
    缓存命中条件:
    1. 文件哈希匹配
    2. 状态为 success
    3. 取最新的记录
    
    Args:
        file_hash: 文件哈希值
    
    Returns:
        如果存在返回记录字典,否则返回 None
    """
    await ensure_db()
    
    import aiosqlite
    
    async with aiosqlite.connect(DB_PATH) as conn:
        conn.row_factory = aiosqlite.Row
        cursor = await conn.execute(
            """
            SELECT * FROM scan_records 
            WHERE file_hash = ? AND status = 'success'
            ORDER BY updated_at DESC LIMIT 1
            """,
            (file_hash,)
        )
        row = await cursor.fetchone()
        
        if row:
            cached = dict(row)
            logger.info("缓存命中:%s", cached['file_name'])
            return cached
    
    logger.debug("缓存未命中:hash=%s", file_hash[:16])
    return None

数据库表结构

python 复制代码
async def initialize_database():
    """初始化 SQLite 数据库。
    
    表结构说明:
    scan_records - 扫描记录表
    - id: 自增主键
    - file_path: 原始文件路径(唯一约束)
    - file_hash: SHA256 哈希值(索引)
    - file_name: 文件名
    - file_size: 文件大小(字节)
    - subject: 科目(默认"数学")
    - grade_level: 年级(默认"高中")
    - status: 状态(pending/success/failed)
    - md_file_path: 输出的 Markdown 文件路径
    - json_file_path: 输出的 JSON 文件路径
    - problem_count: 题目数量
    - created_at: 创建时间
    - updated_at: 更新时间
    - metadata_json: 元数据(JSON 格式)
    """
    import aiosqlite
    
    async with aiosqlite.connect(DB_PATH) as conn:
        # 创建表
        await conn.execute("""
            CREATE TABLE IF NOT EXISTS scan_records (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                file_path TEXT UNIQUE NOT NULL,
                file_hash TEXT NOT NULL,
                file_name TEXT NOT NULL,
                file_size INTEGER,
                subject TEXT DEFAULT '数学',
                grade_level TEXT DEFAULT '高中',
                status TEXT NOT NULL,
                md_file_path TEXT,
                json_file_path TEXT,
                problem_count INTEGER DEFAULT 0,
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL,
                metadata_json TEXT DEFAULT '{}'
            )
        """)
        
        # 创建索引
        await conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_file_hash 
            ON scan_records(file_hash)
        """)
        await conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_status 
            ON scan_records(status)
        """)
        await conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_subject 
            ON scan_records(subject)
        """)
        
        await conn.commit()
    
    logger.info("数据库初始化完成:%s", DB_PATH)

批量处理与增量更新

python 复制代码
async def scan_folder(
    folder_path: Path,
    subject: str = "数学",
    grade_level: str = "高中",
    max_concurrent: int = 3
) -> dict:
    """批量扫描文件夹中的所有图片。
    
    特性:
    1. 自动跳过已处理的文件(通过哈希检查)
    2. 并发控制(避免 API 限流)
    3. 增量更新(只处理新增文件)
    4. 详细统计报告
    
    Args:
        folder_path: 文件夹路径
        subject: 科目
        grade_level: 年级
        max_concurrent: 最大并发数
    
    Returns:
        处理统计信息
    """
    from asyncio import Semaphore
    
    # 找到所有图片文件
    image_extensions = {".jpg", ".jpeg", ".png", ".bmp"}
    image_files = [
        f for f in folder_path.iterdir()
        if f.suffix.lower() in image_extensions
    ]
    
    if not image_files:
        logger.warning("文件夹中没有图片:%s", folder_path)
        return {"total": 0, "processed": 0, "cached": 0, "failed": 0}
    
    logger.info("找到 %d 个图片文件", len(image_files))
    
    # 信号量控制并发
    semaphore = Semaphore(max_concurrent)
    
    # 统计计数器
    stats = {
        "total": len(image_files),
        "processed": 0,
        "cached": 0,
        "failed": 0
    }
    
    async def process_with_semaphore(file_path: Path):
        """带信号量的处理函数。"""
        async with semaphore:
            return await process_single_file(
                file_path, subject, grade_level
            )
    
    # 并发执行
    tasks = [process_with_semaphore(f) for f in image_files]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    
    # 统计结果
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            logger.error("处理失败 %s: %s", image_files[i].name, result)
            stats["failed"] += 1
        elif result:
            if result.get("cached"):
                stats["cached"] += 1
            else:
                stats["processed"] += 1
    
    # 输出报告
    print("\n" + "=" * 60)
    print("📊 批量扫描完成")
    print("=" * 60)
    print(f"总文件数:{stats['total']}")
    print(f"新处理:{stats['processed']} ({stats['processed']/stats['total']*100:.1f}%)")
    print(f"缓存命中:{stats['cached']} ({stats['cached']/stats['total']*100:.1f}%)")
    print(f"处理失败:{stats['failed']} ({stats['failed']/stats['total']*100:.1f}%)")
    print("=" * 60)
    
    return stats


async def process_single_file(
    file_path: Path,
    subject: str,
    grade_level: str
) -> dict:
    """处理单个文件。
    
    流程:
    1. 计算文件哈希
    2. 检查缓存
    3. 如缓存命中,直接返回
    4. 如未命中,调用 GLM-4.6V 解析
    5. 保存结果到数据库
    
    Returns:
        处理结果字典
    """
    logger.info("开始处理:%s", file_path.name)
    
    # 1. 计算哈希
    file_hash = await get_file_hash(file_path)
    
    # 2. 检查缓存
    cached = await check_cache(file_hash)
    
    if cached:
        logger.info("✅ 缓存命中:%s", file_path.name)
        return {
            "cached": True,
            "file_name": cached["file_name"],
            "md_file_path": cached["md_file_path"],
            "problem_count": cached["problem_count"]
        }
    
    # 3. 读取并转换为 base64
    with open(file_path, "rb") as f:
        image_bytes = f.read()
    
    import base64
    img_base64 = base64.b64encode(image_bytes).decode("utf-8")
    
    file_size_mb = file_path.stat().st_size / 1024 / 1024
    logger.info("开始解析:%s (%.2f MB)", file_path.name, file_size_mb)
    
    # 4. 调用 GLM-4.6V
    try:
        result_json = await call_glm_vision(
            img_base64, subject, grade_level
        )
    except Exception as e:
        logger.error("解析失败:%s", e)
        # 保存失败记录
        await save_result(
            file_path, file_hash,
            {"status": "failed", "error": str(e)},
            subject, grade_level
        )
        return None
    
    # 5. 生成输出文件
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    output_filename = f"{file_path.stem}_解析_{timestamp}"
    
    output_dir = Path.home() / ".weclaw" / "scanner_output"
    output_dir.mkdir(parents=True, exist_ok=True)
    
    # 生成 Markdown
    md_content = generate_markdown(result_json, file_path.name)
    md_path = output_dir / f"{output_filename}.md"
    md_path.write_text(md_content, encoding="utf-8")
    
    # 保存 JSON
    json_path = output_dir / f"{output_filename}.json"
    json_path.write_text(
        json.dumps(result_json, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )
    
    problem_count = result_json.get("summary", {}).get("total_problems", 0)
    
    # 6. 保存到数据库
    result_data = {
        "status": "success",
        "md_file_path": str(md_path),
        "json_file_path": str(json_path),
        "problem_count": problem_count,
        "metadata": {
            "original_file": str(file_path),
            "file_size": file_path.stat().st_size,
            "processing_time": datetime.now().isoformat()
        }
    }
    
    await save_result(file_path, file_hash, result_data, subject, grade_level)
    
    logger.info("解析完成:%s (题目数:%d)", file_path.name, problem_count)
    
    return {
        "cached": False,
        "file_name": file_path.name,
        "md_file_path": str(md_path),
        "json_file_path": str(json_path),
        "problem_count": problem_count
    }

📊 测试验证

功能测试

测试项 预期 结果
单文件扫描 正确解析题目 ✅ 通过
批量扫描 自动跳过已处理 ✅ 通过
缓存命中 相同文件秒级返回 ✅ 通过
LaTeX 输出 公式格式正确 ✅ 通过
JSON 解析 结构完整可查询 ✅ 通过
错误处理 友好提示用户 ✅ 通过
并发控制 不超过 API 限流 ✅ 通过

性能指标

场景 平均耗时 缓存命中率 准确率
首次解析 25 秒 0% 95%
缓存命中 0.3 秒 100% 100%
批量处理 N×25 秒 动态 95%
透视校正 0.5 秒 - -

实测案例

测试数据:高中数学试卷(10 道解答题)

指标 数值
图片大小 8.2 MB
分辨率 3264×2448
识别题目数 10 道
平均每题耗时 2.5 秒
输出 Markdown 15 KB
输出 JSON 28 KB
缓存后耗时 0.28 秒

💡 经验教训

1. 图像质量决定识别准确率

教训:初期测试中,光线不足导致识别率从 95% 降至 70%。

解决方案

  • 强制开启高拍仪补光灯
  • 添加图像质量评估(模糊检测)
  • 低于阈值时提示用户重新拍摄
python 复制代码
def check_image_quality(img: np.ndarray) -> float:
    """评估图像质量(0-100)。
    
    基于拉普拉斯方差检测模糊程度。
    """
    import cv2
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    variance = cv2.Laplacian(gray, cv2.CV_64F).var()
    
    # 映射到 0-100
    score = min(100, int(variance / 10))
    return score

2. JSON 解析的容错处理

教训:模型偶尔返回格式不规范的 JSON,导致解析失败。

解决方案:多层降级策略

python 复制代码
def extract_json_from_response(text: str):
    """多层降级提取 JSON。"""
    # 1. 尝试直接解析
    try:
        return json.loads(text)
    except:
        pass
    
    # 2. 提取 Markdown 代码块
    match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', text, re.DOTALL)
    if match:
        try:
            return json.loads(match.group(1))
        except:
            pass
    
    # 3. 找到第一个 { 和最后一个 }
    start = text.find('{')
    end = text.rfind('}') + 1
    if start != -1 and end > start:
        try:
            return json.loads(text[start:end])
        except:
            pass
    
    # 4. 完全失败
    return None

3. 并发控制的必要性

教训:无限制并发导致 API 限流(429 Too Many Requests)。

解决方案 :使用 asyncio.Semaphore 限制并发数

python 复制代码
semaphore = asyncio.Semaphore(3)  # 最多 3 个并发

async def process_with_semaphore(file_path):
    async with semaphore:
        return await process_single_file(file_path)

4. 文件哈希 vs 文件路径

教训:仅用文件路径做唯一键,导致文件重命名后重复解析。

解决方案:使用 SHA256 哈希作为文件指纹

python 复制代码
# ❌ 错误做法
WHERE file_path = ?

# ✅ 正确做法
WHERE file_hash = ?

📊 架构总结

完整数据流

复制代码
高拍仪拍摄
    ↓
图像预处理(去噪/增强/校正)
    ↓
计算 SHA256 哈希
    ↓
检查 SQLite 缓存
    ├─ 命中 → 直接返回
    └─ 未命中 → 继续
         ↓
Base64 编码
    ↓
调用 GLM-4.6V API
    ↓
解析 JSON 响应
    ↓
生成 Markdown + JSON
    ↓
保存到数据库
    ↓
✅ 返回结果

关键技术栈

层次 技术 用途
硬件 CZUR/良田高拍仪 图像捕获
图像处理 OpenCV 预处理/增强
视觉模型 GLM-4.6V 题目理解
HTTP 客户端 httpx API 调用
数据库 SQLite 持久化
异步 asyncio 并发控制
缓存 SHA256 + SQLite 防重复
输出 Jinja2 Markdown 生成

字数统计 : 约 6,800 字
阅读时间 : 约 17 分钟
代码行数: 约 500 行


上一篇文章回顾: 《语音识别系统架构:GLM-ASR 实时流式识别与录音管理》------深入剖析语音输入技术栈。

下一篇文章预告: 《营养食谱推荐引擎:基于规则与协同过滤的混合算法》------如何为家庭成员生成个性化健康食谱。

相关推荐
ん贤2 小时前
AI 大模型落地系列|Eino 组件核心篇:用 Retriever 敲开RAG的大门
人工智能·golang·retriever·eino
风象南2 小时前
Codex 干完活我总是后知后觉,我给它加了一个“完工提醒”
人工智能
廋到被风吹走2 小时前
【Codex】记账APP
人工智能
亚信安全官方账号2 小时前
亚信安全终端安全融合“龙虾”,发布TrustOne 安全助理
大数据·人工智能·安全
xrgs_shz2 小时前
图像的点运算(线性点运算和非线性点运算)
人工智能·算法·机器学习
大模型实验室Lab4AI2 小时前
LlamaFactory 微调实测|Qwen3-4B现代诗风格微调
人工智能·深度学习
lulu12165440782 小时前
IDEA+Claude Code智能辅助:保姆级高效开发教程
java·人工智能·intellij-idea·ai编程
light blue bird2 小时前
多Tab页签高索引组轴可视化图表
jvm·数据库·.net·桌面端·ai大数据
imbackneverdie2 小时前
颠覆科研工作流:AI赋能下的科研模式新变革与MedPeer的全流程解决方案
人工智能·ai·自然语言处理·aigc·科研·ai写作·学术研究