C# 结合 llama.cpp 实现 PaddleOCR-VL-1.5:本地 OCR 客户端开发全攻略

一、前言

在日常工作中,我们经常需要从图片中提取文字信息。虽然市面上有不少 OCR 服务,但它们往往需要联网、存在隐私风险,或者需要付费。2026 年百度发布了开源文档解析模型 PaddleOCR-VL-1.5,该模型不仅支持常规文字识别,还支持表格、公式、图表、印章等任务。更重要的是,它提供了 GGUF 格式版本,可以直接在本地使用 llama.cpp 进行推理。

本文将详细介绍如何使用 C# WinForm 结合 llama.cpp ,打造一个完整的桌面端 OCR 客户端,实现本地离线、安全高效的多功能 OCR 识别。

二、架构总览

整个方案的架构非常简单清晰,由三部分组成:

scss 复制代码
┌─────────────────┐       HTTP (OpenAI API)        ┌─────────────────┐
│   C# WinForm    │  ──────────────────────────>    │  llama-server   │
│   (RestSharp)   │  <──────────────────────────    │  (llama.cpp)    │
└─────────────────┘       JSON Response             └─────────────────┘
                                                            │
                                                            ▼
                                                   ┌─────────────────┐
                                                   │  PaddleOCR-VL   │
                                                   │  1.5 GGUF 模型   │
                                                   └─────────────────┘

llama-server:由 llama.cpp 提供的轻量级 HTTP 服务器,与 OpenAI API 完全兼容,负责加载 GGUF 模型并提供推理 API。

PaddleOCR-VL-1.5 GGUF 模型:包含模型权重和视觉投影仪两个文件。

C# WinForm 客户端:使用 RestSharp 通过 HTTP 调用本地服务,实现图片选择、发送、结果显示的全流程。

这种架构的好处非常明显:服务端与客户端完全解耦,你可以随时升级服务端版本或更换模型,而无需修改任何客户端代码。

三、环境准备

scss 复制代码
组件 版本/说明
llama.cpp b9101 (预编译 CUDA 12.4 版本)
模型文件 PaddleOCR-VL-1.5-GGUF.gguf + PaddleOCR-VL-1.5-GGUF-mmproj.gguf
.NET .NET Framework 4.8
C# 语言版本 7.3+
RestSharp 114.x (v107+ 新 API)
Newtonsoft.Json 13.0.3

四、服务端启动

启动 llama-server 先进入 llama.cpp 的可执行文件目录,打开终端执行:

css 复制代码
llama-server.exe -m ../PaddleOCR-VL-1.5-GGUF/PaddleOCR-VL-1.5.gguf --mmproj ../PaddleOCR-VL-1.5-GGUF/PaddleOCR-VL-1.5-mmproj.gguf --port 8080 --host 0.0.0.0 --temp 0

关键参数解读:

-m 指定 GGUF 模型文件路径

--mmproj 指定多模态投影仪文件(VLM 必需)

--port 8080 服务监听端口

--host 0.0.0.0 允许局域网其他设备访问

--temp 0 温度设为 0,使输出结果确定、稳定

效果





客户端C#代码

csharp 复制代码
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using RestSharp;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Security.Cryptography;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace PaddleOCR_Client
{
    public partial class Form1 : Form
    {
        // 定义 PaddleOCR-VL 支持的核心任务类型
        public enum OcrTaskType
        {
            ocr,        // 文字识别
            formula,    // 公式识别
            table,      // 表格识别
            chart,      // 图表识别
            seal       // 印章识别
        }

        // 内部结果类,包含识别文本及分阶段耗时
        private class OcrResult
        {
            public string Text { get; set; }
            public Dictionary<string, long> Timings { get; set; } = new Dictionary<string, long>();
        }

        public Form1()
        {
            InitializeComponent();
        }

        private string currentImagePath;

        private void btnSelectImage_Click(object sender, EventArgs e)
        {
            using (var dlg = new OpenFileDialog())
            {
                dlg.Filter = "图片文件|*.jpg;*.jpeg;*.png;*.bmp";

                string defaultDir = System.IO.Path.Combine(System.Windows.Forms.Application.StartupPath, "test_img");
                dlg.InitialDirectory = defaultDir;

                if (dlg.ShowDialog() != DialogResult.OK) return;

                currentImagePath = dlg.FileName;
                pictureBox1.Image = new Bitmap(currentImagePath);
                txtResult.Text = string.Empty;
            }
        }

        // 核心任务调度器(已包含服务端推理时间展示)
        private async Task ExecuteOcrTask(OcrTaskType taskType)
        {
            if (string.IsNullOrEmpty(currentImagePath))
            {
                MessageBox.Show("请先选择一张图片", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
                return;
            }

            SetButtonsEnabled(false);
            txtResult.Text = $"正在进行{taskType}任务,请稍候...";

            var swTotal = Stopwatch.StartNew();
            try
            {
                OcrResult result = await OcrImageGeneralAsync(currentImagePath, taskType);
                swTotal.Stop();

                // 构建耗时分项信息
                string timingDetails = "【各阶段耗时】\r\n";
                foreach (var kvp in result.Timings)
                {
                    timingDetails += $"  {kvp.Key}: {kvp.Value} ms\r\n";
                }

                // 换行显示问题
                string displayText = result.Text.Replace("\n", Environment.NewLine);

                txtResult.Text = $"【{taskType}任务完成】\r\n" +
                                 $"客户端总耗时:{swTotal.ElapsedMilliseconds} ms\r\n" +
                                 timingDetails +
                                 $"------------------------------------------\r\n" +
                                 displayText;
            }
            catch (Exception ex)
            {
                swTotal.Stop();
                txtResult.Text = $"【{taskType}任务失败】\r\n" +
                                 $"客户端总耗时:{swTotal.ElapsedMilliseconds} ms\r\n" +
                                 $"错误信息:{ex.Message}";
            }
            finally
            {
                SetButtonsEnabled(true);
            }
        }

        private void Form1_Load(object sender, EventArgs e) { }

        /// <summary>
        /// 通用的OCR/VL任务调用方法,返回识别结果及分步耗时(含服务端推理耗时)
        /// </summary>
        private async Task<OcrResult> OcrImageGeneralAsync(string imagePath, OcrTaskType taskType)
        {
            var result = new OcrResult();
            var sw = Stopwatch.StartNew();

            // 步骤1:读取文件
            byte[] imgBytes = File.ReadAllBytes(imagePath);
            result.Timings["读取文件"] = sw.ElapsedMilliseconds;
            sw.Restart();

            // 步骤2:Base64编码
            string mime = GetMimeType(Path.GetExtension(imagePath));
            string base64Image = $"data:{mime};base64,{Convert.ToBase64String(imgBytes)}";
            result.Timings["Base64编码"] = sw.ElapsedMilliseconds;
            sw.Restart();

            // 步骤3:构造请求(Payload序列化)
            string taskPrompt = BuildPromptForTask(taskType);
            var payload = new
            {
                messages = new[]
                {
                    new
                    {
                        role = "user",
                        content = new object[]
                        {
                            new { type = "image_url", image_url = new { url = base64Image } },
                            new { type = "text", text = taskPrompt }
                        }
                    }
                }
            };
            string jsonBody = JsonConvert.SerializeObject(payload);
            result.Timings["构造请求"] = sw.ElapsedMilliseconds;
            sw.Restart();

            // 步骤4:发送HTTP请求并等待响应
            var options = new RestClientOptions("http://localhost:8080"); // 你的启动端口
            using (var client = new RestClient(options))
            {
                var request = new RestRequest("/v1/chat/completions", Method.Post);
                request.AddHeader("Content-Type", "application/json");
                request.AddParameter("application/json", jsonBody, ParameterType.RequestBody);

                RestResponse response = await client.ExecuteAsync(request);
                result.Timings["网络请求"] = sw.ElapsedMilliseconds;
                sw.Restart();

                if (!response.IsSuccessful)
                {
                    string errorDetail = string.IsNullOrEmpty(response.Content) ? response.StatusDescription : response.Content;
                    throw new Exception($"服务器错误 ({response.StatusCode}): {errorDetail}");
                }

                // 步骤5:解析响应JSON
                JObject jResult = JObject.Parse(response.Content);
                string content = jResult["choices"]?[0]?["message"]?["content"]?.ToString();
                result.Timings["解析响应"] = sw.ElapsedMilliseconds;

                // 提取服务端推理耗时 (prompt_ms + predicted_ms)
                JToken timingsToken = jResult["timings"];
                if (timingsToken != null)
                {
                    double promptMs = timingsToken.Value<double>("prompt_ms");
                    double predictedMs = timingsToken.Value<double>("predicted_ms");
                    result.Timings["服务端编码(Prompt)"] = (long)promptMs;
                    result.Timings["服务端生成(Predict)"] = (long)predictedMs;
                    result.Timings["服务端总推理"] = (long)(promptMs + predictedMs);
                }

                sw.Stop();

                string finalText = content ?? "未能提取到识别文本";

                result.Text = finalText;
                return result;
            }
        }


        /// <summary>
        /// 为不同任务构建提示词
        /// </summary>
        private string BuildPromptForTask(OcrTaskType taskType)
        {
            switch (taskType)
            {
                case OcrTaskType.ocr:
                    return"<__media__>OCR:";

                case OcrTaskType.formula:
                    return"<__media__>Formula:";

                case OcrTaskType.table:
                    return"<__media__>Table:";

                case OcrTaskType.chart:
                    return"<__media__>Chart:";

                case OcrTaskType.seal:
                    return"<__media__>Seal:";

                default:
                    return"<__media__>OCR:";
            }
        }

        /// <summary>
        /// 根据扩展名获取MIME类型
        /// </summary>
        private string GetMimeType(string ext)
        {
            switch (ext.ToLower())
            {
                case".jpg":
                case".jpeg":
                    return"image/jpeg";
                case".png":
                    return"image/png";
                case".bmp":
                    return"image/bmp";
                default:
                    return"image/jpeg";
            }
        }

        /// <summary>
        /// 统一设置所有功能按钮的启用/禁用状态
        /// </summary>
        private void SetButtonsEnabled(bool enabled)
        {
            btnOCR.Enabled = enabled;
            btnFormula.Enabled = enabled;
            btnTable.Enabled = enabled;
            btnChart.Enabled = enabled;
            btnSeal.Enabled = enabled;
        }

        // 各任务按钮事件处理
        async private void btnOCR_Click(object sender, EventArgs e)
        {
            await ExecuteOcrTask(OcrTaskType.ocr);
        }

        async private void btnFormula_Click(object sender, EventArgs e)
        {
            await ExecuteOcrTask(OcrTaskType.formula);
        }

        async private void btnTable_Click(object sender, EventArgs e)
        {
            await ExecuteOcrTask(OcrTaskType.table);
        }

        async private void btnChart_Click(object sender, EventArgs e)
        {
            await ExecuteOcrTask(OcrTaskType.chart);
        }

        async private void btnSeal_Click(object sender, EventArgs e)
        {
            await ExecuteOcrTask(OcrTaskType.seal);
        }

    }
}
相关推荐
o_insist1 小时前
多层感知机判断氨基酸亲疏水性(PyTorch版)
人工智能·深度学习·机器学习
AICAT1 小时前
让主题模型“心领神会”:GCTM-OT如何用目标提示与最优传输终结跑偏话题
人工智能
数字时代全景窗1 小时前
数字的长征:从蒸汽机到智能体——可计算化革命的底层演进脉络
人工智能·架构·软件工程
LinDaiDai_霖呆呆1 小时前
大白话介绍大模型的一些底层原理,看完终于能跟人聊两句了
前端·人工智能·面试
workflower1 小时前
从拿订单到看方向
大数据·人工智能·设计模式·机器人·动态规划
蜘蛛小助理1 小时前
HR 效率神器:零代码搭建招聘 + 考勤 + 薪酬一体化管理系统
人工智能·ai·人事管理·hr·多维表格·蜘蛛表格
数智化管理手记2 小时前
设备总停机?找准根源+TPM核心逻辑,筑牢零故障基础
数据库·人工智能·低代码·制造
青山师2 小时前
【AI热点资讯】5月10日AI热点:Cloudflare裁员1100人、Musk庭审第二周回顾、OpenAI发布Codex Chrome插件
前端·人工智能·chrome·ai·ai热点
长亭外的少年2 小时前
从 Prompt 到工程体系:如何真正把 AI 用进软件开发
人工智能·prompt