279 lines
12 KiB
C#
279 lines
12 KiB
C#
// ============================================================
|
|
// QianfanOcrClient:百度千帆 PaddleOCR-VL-0.9B(版面解析)
|
|
// · POST https://qianfan.baidubce.com/v2/ocr/paddleocr
|
|
// Authorization: Bearer {QianfanApiKey}(千帆 ModelBuilder 的 API Key,永久有效)
|
|
// {"model":"paddleocr-vl-0.9b","file":"<纯base64>","fileType":1}
|
|
// · 响应 result.layoutParsingResults[0].prunedResult.parsing_res_list[]
|
|
// 每块 {block_label, block_content, block_bbox[x1,y1,x2,y2](像素)}
|
|
// → 文本块转 OcrItem;表格块逐行拆(与 GLM 处理一致)
|
|
// · Key 存 baidu_ocr.json 的 qianfan_api_key(OCR设置窗口写)
|
|
// ============================================================
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Drawing;
|
|
using System.Drawing.Imaging;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using System.Web.Script.Serialization;
|
|
|
|
namespace 成套报价软件
|
|
{
|
|
/// <summary>百度千帆 PaddleOCR-VL 客户端(单实例)。</summary>
|
|
internal sealed class QianfanOcrClient
|
|
{
|
|
private readonly JavaScriptSerializer _json = new JavaScriptSerializer();
|
|
private string _apiKey;
|
|
private const string BaseUrl = "https://qianfan.baidubce.com/v2/ocr/paddleocr";
|
|
|
|
/// <summary>状态提示,接 OcrClient.StatusChanged。</summary>
|
|
public event Action<string> StatusChanged;
|
|
|
|
public QianfanOcrClient()
|
|
{
|
|
try
|
|
{
|
|
ServicePointManager.SecurityProtocol =
|
|
ServicePointManager.SecurityProtocol | SecurityProtocolType.Tls12;
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
public bool IsConfigured
|
|
{
|
|
get { return !string.IsNullOrWhiteSpace(_apiKey); }
|
|
}
|
|
|
|
/// <summary>Key 由 OcrClient 从 baidu_ocr.json 的 qianfan_api_key 注入(热更新)。</summary>
|
|
public void SetApiKey(string key)
|
|
{
|
|
_apiKey = key == null ? "" : key.Trim();
|
|
}
|
|
|
|
/// <summary>识别一块位图 → 文本块列表(带 bbox 像素坐标)。</summary>
|
|
public Task<List<OcrItem>> RecognizeAsync(Bitmap crop, int timeoutMs)
|
|
{
|
|
if (crop == null || crop.Width <= 0 || crop.Height <= 0)
|
|
return Task.FromResult(new List<OcrItem>());
|
|
|
|
if (!IsConfigured)
|
|
{
|
|
Notify("千帆 PaddleOCR-VL 未配置 API Key(OCR 设置窗口里填千帆 API Key)");
|
|
var tcs0 = new TaskCompletionSource<List<OcrItem>>();
|
|
tcs0.SetException(new Exception(
|
|
"千帆 PaddleOCR-VL 未配置 API Key。\r\n" +
|
|
"请到百度智能云千帆平台(ModelBuilder)创建 API Key," +
|
|
"在 OCR 设置窗口选择引擎\"百度 PaddleOCR-VL\"并填入后保存。"));
|
|
return tcs0.Task;
|
|
}
|
|
|
|
return Task.Factory.StartNew(() =>
|
|
{
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
var result = RequestAndParse(crop, timeoutMs);
|
|
Notify("PaddleOCR-VL·" + result.Count + "条 " + sw.ElapsedMilliseconds + "ms");
|
|
return result;
|
|
});
|
|
}
|
|
|
|
private List<OcrItem> RequestAndParse(Bitmap crop, int timeoutMs)
|
|
{
|
|
byte[] png;
|
|
using (var ms = new MemoryStream())
|
|
{
|
|
crop.Save(ms, ImageFormat.Png);
|
|
png = ms.ToArray();
|
|
}
|
|
// 千帆要求:base64 直传(无 data URI 前缀),fileType=1 图像
|
|
string body = _json.Serialize(new Dictionary<string, object>
|
|
{
|
|
{ "model", "paddleocr-vl-0.9b" },
|
|
{ "file", Convert.ToBase64String(png) },
|
|
{ "fileType", 1 }
|
|
});
|
|
|
|
var req = (HttpWebRequest)WebRequest.Create(BaseUrl);
|
|
req.Method = "POST";
|
|
req.ContentType = "application/json";
|
|
req.Headers.Add("Authorization", "Bearer " + _apiKey);
|
|
req.Timeout = timeoutMs;
|
|
req.ReadWriteTimeout = timeoutMs;
|
|
req.Proxy = null;
|
|
var bytes = Encoding.UTF8.GetBytes(body);
|
|
req.ContentLength = bytes.Length;
|
|
using (var rs = req.GetRequestStream())
|
|
{
|
|
rs.Write(bytes, 0, bytes.Length);
|
|
}
|
|
|
|
string respText;
|
|
try
|
|
{
|
|
using (var resp = (HttpWebResponse)req.GetResponse())
|
|
using (var sr = new StreamReader(resp.GetResponseStream(), new UTF8Encoding(false)))
|
|
{
|
|
respText = sr.ReadToEnd();
|
|
}
|
|
}
|
|
catch (WebException wex)
|
|
{
|
|
string detail = "";
|
|
try
|
|
{
|
|
if (wex.Response != null)
|
|
using (var sr = new StreamReader(wex.Response.GetResponseStream(), new UTF8Encoding(false)))
|
|
detail = sr.ReadToEnd();
|
|
}
|
|
catch { }
|
|
string hint = wex.Response == null
|
|
? ("网络不可达: " + wex.Message)
|
|
: ("HTTP " + (int)((HttpWebResponse)wex.Response).StatusCode + " " + detail);
|
|
throw new Exception("PaddleOCR-VL 请求失败: " + hint);
|
|
}
|
|
|
|
return ParseResponse(respText, crop.Width, crop.Height);
|
|
}
|
|
|
|
// ---- 响应解析(行拆分/清理逻辑与 GLM 一致,复用其 shared 方法) ----
|
|
|
|
private List<OcrItem> ParseResponse(string respText, int cropW, int cropH)
|
|
{
|
|
var msg = _json.Deserialize<Dictionary<string, object>>(respText);
|
|
if (msg == null) throw new Exception("千帆响应解析失败(空响应)");
|
|
if (msg.ContainsKey("error"))
|
|
{
|
|
var err = msg["error"] as Dictionary<string, object>;
|
|
string code = err != null && err.ContainsKey("code") ? Convert.ToString(err["code"]) : "?";
|
|
string message = err != null && err.ContainsKey("message") ? Convert.ToString(err["message"]) : respText;
|
|
throw new Exception("PaddleOCR-VL 错误 [" + code + "]: " + message);
|
|
}
|
|
|
|
var items = new List<OcrItem>();
|
|
// result.layoutParsingResults[0].prunedResult.parsing_res_list
|
|
var result = msg.ContainsKey("result") ? msg["result"] as Dictionary<string, object> : null;
|
|
var layouts = result != null && result.ContainsKey("layoutParsingResults")
|
|
? result["layoutParsingResults"] as System.Collections.ArrayList : null;
|
|
if (layouts == null || layouts.Count == 0)
|
|
throw new Exception("PaddleOCR-VL 响应没有 layoutParsingResults(内容: " +
|
|
(respText.Length > 300 ? respText.Substring(0, 300) : respText) + ")");
|
|
|
|
var first = layouts[0] as Dictionary<string, object>;
|
|
var pruned = first != null && first.ContainsKey("prunedResult")
|
|
? first["prunedResult"] as Dictionary<string, object> : null;
|
|
var blocks = pruned != null && pruned.ContainsKey("parsing_res_list")
|
|
? pruned["parsing_res_list"] as System.Collections.ArrayList : null;
|
|
if (blocks == null) return items;
|
|
|
|
foreach (Dictionary<string, object> b in blocks)
|
|
{
|
|
string label = b.ContainsKey("block_label") ? Convert.ToString(b["block_label"]) : "";
|
|
string content = b.ContainsKey("block_content") ? Convert.ToString(b["block_content"]) : "";
|
|
if (string.IsNullOrWhiteSpace(content)) continue;
|
|
|
|
var bbox = b.ContainsKey("block_bbox") ? b["block_bbox"] as System.Collections.ArrayList : null;
|
|
if (bbox == null || bbox.Count < 4) continue;
|
|
|
|
// block_bbox 像素坐标 [x1,y1,x2,y2];裁剪到图内
|
|
float x1 = Math.Max(0, Math.Min(cropW, Convert.ToSingle(bbox[0])));
|
|
float y1 = Math.Max(0, Math.Min(cropH, Convert.ToSingle(bbox[1])));
|
|
float x2 = Math.Max(0, Math.Min(cropW, Convert.ToSingle(bbox[2])));
|
|
float y2 = Math.Max(0, Math.Min(cropH, Convert.ToSingle(bbox[3])));
|
|
if (x2 - x1 < 2 || y2 - y1 < 2) continue;
|
|
|
|
if (label == "table")
|
|
{
|
|
AddTableItems(items, content, x1, y1, x2, y2);
|
|
continue;
|
|
}
|
|
if (label != "text" && label != "title" && label != "paragraph")
|
|
continue; // 图片/公式/图表块跳过(title 也是文字,收)
|
|
|
|
content = GlmOcrClient.CleanHtmlShared(content);
|
|
if (string.IsNullOrWhiteSpace(content)) continue;
|
|
|
|
string[] lines = content.Split(new[] { '\r', '\n' },
|
|
StringSplitOptions.RemoveEmptyEntries);
|
|
float lineH = (y2 - y1) / lines.Length;
|
|
float pad = Math.Min(lineH * 0.15f, 3f);
|
|
for (int i = 0; i < lines.Length; i++)
|
|
{
|
|
string text = GlmOcrClient.CleanMarkdownShared(lines[i]).Trim();
|
|
if (text.Length == 0) continue;
|
|
float ty1 = y1 + lineH * i + pad;
|
|
float ty2 = y1 + lineH * (i + 1) - pad;
|
|
if (ty2 - ty1 < 2) { ty1 = y1 + lineH * i; ty2 = ty1 + Math.Max(2, lineH); }
|
|
items.Add(new OcrItem
|
|
{
|
|
Text = text,
|
|
Box = new[]
|
|
{
|
|
new PointF(x1, ty1),
|
|
new PointF(x2, ty1),
|
|
new PointF(x2, ty2),
|
|
new PointF(x1, ty2)
|
|
},
|
|
Score = 1f
|
|
});
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
private static readonly System.Text.RegularExpressions.Regex _trRegex =
|
|
new System.Text.RegularExpressions.Regex("<tr[^>]*>(.*?)</tr>",
|
|
System.Text.RegularExpressions.RegexOptions.Singleline |
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase |
|
|
System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
private static readonly System.Text.RegularExpressions.Regex _tdRegex =
|
|
new System.Text.RegularExpressions.Regex("<t[dh][^>]*>(.*?)</t[dh]>",
|
|
System.Text.RegularExpressions.RegexOptions.Singleline |
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase |
|
|
System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
/// <summary>表格块 → 每 tr 一条 item(td 内容空格拼接),行高均分 bbox。</summary>
|
|
private static void AddTableItems(List<OcrItem> items, string tableHtml,
|
|
float x1, float y1, float x2, float y2)
|
|
{
|
|
var rows = new List<string>();
|
|
foreach (System.Text.RegularExpressions.Match tr in _trRegex.Matches(tableHtml))
|
|
{
|
|
var cells = new List<string>();
|
|
foreach (System.Text.RegularExpressions.Match td in _tdRegex.Matches(tr.Groups[1].Value))
|
|
{
|
|
string cell = GlmOcrClient.CleanHtmlShared(td.Groups[1].Value).Trim();
|
|
if (cell.Length > 0) cells.Add(cell);
|
|
}
|
|
if (cells.Count > 0) rows.Add(string.Join(" ", cells.ToArray()));
|
|
}
|
|
if (rows.Count == 0) return;
|
|
float rowH = (y2 - y1) / rows.Count;
|
|
float pad = Math.Min(rowH * 0.15f, 3f);
|
|
for (int i = 0; i < rows.Count; i++)
|
|
{
|
|
float ty1 = y1 + rowH * i + pad;
|
|
float ty2 = y1 + rowH * (i + 1) - pad;
|
|
if (ty2 - ty1 < 2) { ty1 = y1 + rowH * i; ty2 = ty1 + Math.Max(2, rowH); }
|
|
items.Add(new OcrItem
|
|
{
|
|
Text = rows[i],
|
|
Box = new[]
|
|
{
|
|
new PointF(x1, ty1),
|
|
new PointF(x2, ty1),
|
|
new PointF(x2, ty2),
|
|
new PointF(x1, ty2)
|
|
},
|
|
Score = 1f
|
|
});
|
|
}
|
|
}
|
|
|
|
private void Notify(string s)
|
|
{
|
|
var h = StatusChanged;
|
|
if (h != null) h(s);
|
|
}
|
|
}
|
|
}
|