418 lines
19 KiB
C#
418 lines
19 KiB
C#
// ============================================================
|
|
// OcrClient:OCR 引擎路由(baidu_ocr.json 的 engine 字段选择)
|
|
// · engine=baidu(默认):百度 OCR 高精度含位置版(HTTP 直连)
|
|
// · engine=glm:智谱 GLM-OCR 版面解析(手写字强,GlmOcrClient)
|
|
// · Key 都在 exe 旁 baidu_ocr.json(OCR设置窗口写):
|
|
// api_key/secret_key=百度;glm_api_key=智谱
|
|
// 百度未保存过/Key 为空 → 内置默认 Key 直接可用
|
|
// · 百度 access_token 缓存 exe 旁 baidu_token.json(29 天,提前1天过期)
|
|
// · 未配 Key / 网络失败直接抛异常(界面提示),无本地回退
|
|
// ============================================================
|
|
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 pdfbt
|
|
{
|
|
public class OcrItem
|
|
{
|
|
public string Text;
|
|
public PointF[] Box; // 裁剪图像素坐标,4 点(左上起顺时针)
|
|
public float Score;
|
|
|
|
public RectangleF Bounds
|
|
{
|
|
get
|
|
{
|
|
if (Box == null || Box.Length == 0) return RectangleF.Empty;
|
|
float l = Box[0].X, t = Box[0].Y, r = Box[0].X, b = Box[0].Y;
|
|
foreach (var p in Box)
|
|
{
|
|
if (p.X < l) l = p.X; if (p.X > r) r = p.X;
|
|
if (p.Y < t) t = p.Y; if (p.Y > b) b = p.Y;
|
|
}
|
|
return RectangleF.FromLTRB(l, t, r, b);
|
|
}
|
|
}
|
|
}
|
|
|
|
public class OcrClient : IDisposable
|
|
{
|
|
public event Action<string> StatusChanged;
|
|
|
|
private readonly JavaScriptSerializer _json = new JavaScriptSerializer();
|
|
private string _ak = "", _sk = "";
|
|
private string _token;
|
|
private DateTime _tokenExpire = DateTime.MinValue;
|
|
|
|
// ★ 引擎路由:baidu(默认)/glm(智谱 GLM-OCR)/qianfan(百度 PaddleOCR-VL)/uapis(UApiPro)/umi(Umi-OCR 本地)
|
|
private string _engine = "baidu";
|
|
private readonly GlmOcrClient _glm = new GlmOcrClient();
|
|
private readonly QianfanOcrClient _qianfan = new QianfanOcrClient();
|
|
private readonly UapisOcrClient _uapis = new UapisOcrClient();
|
|
private readonly UmiOcrClient _umi = new UmiOcrClient();
|
|
|
|
/// <summary>当前 OCR 引擎(baidu/glm/qianfan/uapis/umi)。</summary>
|
|
public string Engine
|
|
{
|
|
get { return _engine; }
|
|
}
|
|
|
|
// 内置默认 Key(Base64 混淆,exe 内非明文):未保存过配置/Key 为空时直接使用
|
|
private static readonly string _defAk = Encoding.UTF8.GetString(Convert.FromBase64String("R0hjbjVxRmJLcG5TY3NyZHlIV1M4dzBB"));
|
|
private static readonly string _defSk = Encoding.UTF8.GetString(Convert.FromBase64String("VkMxVVU2RXlxN28wcGRLNElCZ1A4ZnNhWjIyQ3RlMWM="));
|
|
|
|
public OcrClient()
|
|
{
|
|
_glm.StatusChanged += msg =>
|
|
{
|
|
var h = StatusChanged;
|
|
if (h != null) h(msg);
|
|
};
|
|
_qianfan.StatusChanged += msg =>
|
|
{
|
|
var h = StatusChanged;
|
|
if (h != null) h(msg);
|
|
};
|
|
_uapis.StatusChanged += msg =>
|
|
{
|
|
var h = StatusChanged;
|
|
if (h != null) h(msg);
|
|
};
|
|
_umi.StatusChanged += msg =>
|
|
{
|
|
var h = StatusChanged;
|
|
if (h != null) h(msg);
|
|
};
|
|
LoadConfig();
|
|
}
|
|
|
|
/// <summary>读 baidu_ocr.json(OCR设置窗口保存后调用可热更新 Key/引擎)。</summary>
|
|
public void LoadConfig()
|
|
{
|
|
try
|
|
{
|
|
string p = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "baidu_ocr.json");
|
|
if (File.Exists(p))
|
|
{
|
|
var jsv = _json.Deserialize<Dictionary<string, string>>(File.ReadAllText(p));
|
|
if (jsv != null)
|
|
{
|
|
_ak = jsv.ContainsKey("api_key") ? (jsv["api_key"] ?? "") : "";
|
|
_sk = jsv.ContainsKey("secret_key") ? (jsv["secret_key"] ?? "") : "";
|
|
_engine = jsv.ContainsKey("engine") ? (jsv["engine"] ?? "baidu") : "baidu";
|
|
_glm.SetApiKey(jsv.ContainsKey("glm_api_key") ? (jsv["glm_api_key"] ?? "") : "");
|
|
_qianfan.SetApiKey(jsv.ContainsKey("qianfan_api_key") ? (jsv["qianfan_api_key"] ?? "") : "");
|
|
_uapis.SetApiKey(jsv.ContainsKey("uapis_api_key") ? (jsv["uapis_api_key"] ?? "") : "");
|
|
_umi.SetBaseUrl(jsv.ContainsKey("umi_url") ? (jsv["umi_url"] ?? "") : "");
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
// 缺省回退内置默认 Key:装完即用,无需打开设置点保存;用户保存过则文件值优先
|
|
if (string.IsNullOrEmpty(_ak)) _ak = _defAk;
|
|
if (string.IsNullOrEmpty(_sk)) _sk = _defSk;
|
|
if (_engine != "baidu" && _engine != "glm" && _engine != "qianfan" && _engine != "uapis" && _engine != "umi")
|
|
_engine = "baidu";
|
|
}
|
|
|
|
public bool HasKey
|
|
{
|
|
get { return !string.IsNullOrEmpty(_ak) && !string.IsNullOrEmpty(_sk); }
|
|
}
|
|
|
|
private void Notify(string msg)
|
|
{
|
|
var h = StatusChanged;
|
|
if (h != null) h(msg);
|
|
}
|
|
|
|
/// <summary>获取 access_token(内存→文件缓存→在线换取)。失败抛异常。</summary>
|
|
private string GetToken()
|
|
{
|
|
if (_token != null && DateTime.Now < _tokenExpire) return _token;
|
|
|
|
string cachePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "baidu_token.json");
|
|
try
|
|
{
|
|
if (File.Exists(cachePath))
|
|
{
|
|
var c = _json.Deserialize<Dictionary<string, object>>(File.ReadAllText(cachePath));
|
|
if (c != null && c.ContainsKey("token") && c.ContainsKey("expire"))
|
|
{
|
|
double exp;
|
|
double.TryParse(Convert.ToString(c["expire"]), out exp);
|
|
string tok = Convert.ToString(c["token"]);
|
|
if (tok.Length > 0 && DateTime.Now < new DateTime(1970, 1, 1).AddSeconds(exp))
|
|
{
|
|
_token = tok;
|
|
_tokenExpire = new DateTime(1970, 1, 1).AddSeconds(exp);
|
|
return _token;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
if (!HasKey)
|
|
throw new Exception("未配置百度 OCR Key(工具栏 OCR设置 填写 API Key / Secret Key)");
|
|
|
|
string url = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials"
|
|
+ "&client_id=" + Uri.EscapeDataString(_ak)
|
|
+ "&client_secret=" + Uri.EscapeDataString(_sk);
|
|
using (var wc = new WebClient())
|
|
{
|
|
wc.Encoding = Encoding.UTF8;
|
|
wc.Headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
string body = wc.UploadString(url, "POST", "");
|
|
var resp = _json.Deserialize<Dictionary<string, object>>(body);
|
|
if (resp == null || !resp.ContainsKey("access_token"))
|
|
throw new Exception("百度 token 获取失败: " +
|
|
(resp != null && resp.ContainsKey("error_description")
|
|
? Convert.ToString(resp["error_description"])
|
|
: body.Substring(0, Math.Min(120, body.Length))));
|
|
_token = Convert.ToString(resp["access_token"]);
|
|
double expires;
|
|
double.TryParse(Convert.ToString(resp.ContainsKey("expires_in") ? resp["expires_in"] : "2592000"), out expires);
|
|
_tokenExpire = DateTime.Now.AddSeconds(Math.Max(3600, expires - 86400));
|
|
try
|
|
{
|
|
var cache = new Dictionary<string, object>
|
|
{
|
|
{ "token", _token },
|
|
{ "expire", (_tokenExpire - new DateTime(1970, 1, 1)).TotalSeconds }
|
|
};
|
|
File.WriteAllText(cachePath, _json.Serialize(cache));
|
|
}
|
|
catch { }
|
|
return _token;
|
|
}
|
|
}
|
|
|
|
/// <summary>识别位图(300DPI 裁剪)。失败抛异常;成功通知引擎来源。
|
|
/// 竖排文字(实为旋转 90° 的横排)直接识别必乱码:细高框或平均置信度偏低时,
|
|
/// 旋转 ±90° 重识别并按平均置信度选优,坐标逆向还原回原图。</summary>
|
|
public Task<List<OcrItem>> RecognizeAsync(Bitmap crop, int timeoutMs)
|
|
{
|
|
// ★ 引擎路由:智谱 GLM-OCR / 千帆 PaddleOCR-VL(版面解析,不走旋转重试)
|
|
if (_engine == "glm")
|
|
return _glm.RecognizeAsync(crop, timeoutMs);
|
|
if (_engine == "qianfan")
|
|
return _qianfan.RecognizeAsync(crop, timeoutMs);
|
|
if (_engine == "uapis")
|
|
return _uapis.RecognizeAsync(crop, timeoutMs);
|
|
if (_engine == "umi")
|
|
return _umi.RecognizeAsync(crop, timeoutMs);
|
|
|
|
var tcs = new TaskCompletionSource<List<OcrItem>>();
|
|
Task.Factory.StartNew(delegate
|
|
{
|
|
try
|
|
{
|
|
var items = RecognizeOnce(crop, timeoutMs);
|
|
try
|
|
{
|
|
if (crop.Height >= crop.Width * 1.15f || AvgScore(items) < 0.88f)
|
|
{
|
|
float best = AvgScore(items);
|
|
foreach (bool cw in new bool[] { true, false })
|
|
{
|
|
using (var turned = new Bitmap(crop))
|
|
{
|
|
turned.RotateFlip(cw ? RotateFlipType.Rotate90FlipNone : RotateFlipType.Rotate270FlipNone);
|
|
var rr = RecognizeOnce(turned, timeoutMs);
|
|
UnrotateItems(rr, cw, crop.Width, crop.Height);
|
|
float s = AvgScore(rr);
|
|
if (s > best + 0.01f) { best = s; items = rr; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch { /* 旋转重试失败:保留原方向结果 */ }
|
|
Notify("百度OCR·高精度含位置版");
|
|
tcs.TrySetResult(items);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
tcs.TrySetException(ex);
|
|
}
|
|
});
|
|
return tcs.Task;
|
|
}
|
|
|
|
/// <summary>单方向识别一次(编码→上传→解析);Box 为入参图像的像素坐标。</summary>
|
|
private List<OcrItem> RecognizeOnce(Bitmap img, int timeoutMs)
|
|
{
|
|
// 编码为百度限额内(高精度版 base64 后 ≤4MB)的 JPEG;返回缩放比用于还原识别坐标
|
|
float sendScale;
|
|
string b64 = EncodeFit(img, out sendScale);
|
|
string tok = GetToken();
|
|
string url = "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate?access_token="
|
|
+ Uri.EscapeDataString(tok);
|
|
// Base64 的表单转义不能用 Uri.EscapeDataString:.NET Framework 对超长字符串
|
|
// (约 32K 字符)抛"无效的 URI: URI 字符串太长",大图 base64 必中
|
|
string form = "image=" + EscapeBase64(b64)
|
|
+ "&language_type=CHN_ENG&probability=true";
|
|
using (var wc = new WebClient())
|
|
{
|
|
wc.Encoding = Encoding.UTF8;
|
|
wc.Headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
string body = wc.UploadString(url, "POST", form);
|
|
var resp = _json.Deserialize<Dictionary<string, object>>(body);
|
|
if (resp == null)
|
|
throw new Exception("百度OCR返回解析失败");
|
|
if (resp.ContainsKey("error_code") && Convert.ToInt64(resp["error_code"]) != 0)
|
|
throw new Exception("百度OCR错误 " + Convert.ToString(resp["error_code"]) + ": "
|
|
+ Convert.ToString(resp.ContainsKey("error_msg") ? resp["error_msg"] : ""));
|
|
var items = new List<OcrItem>();
|
|
var arr = resp.ContainsKey("words_result") ? resp["words_result"] as System.Collections.ArrayList : null;
|
|
if (arr != null)
|
|
{
|
|
foreach (Dictionary<string, object> w in arr)
|
|
{
|
|
var loc = (w != null && w.ContainsKey("location")) ? w["location"] as Dictionary<string, object> : null;
|
|
if (loc == null) continue;
|
|
float l = LocF(loc, "left"), t = LocF(loc, "top");
|
|
float w2 = LocF(loc, "width"), h = LocF(loc, "height");
|
|
float score = 0.95f;
|
|
var prob = w.ContainsKey("probability") ? w["probability"] as Dictionary<string, object> : null;
|
|
if (prob != null && prob.ContainsKey("average"))
|
|
{
|
|
float sv;
|
|
if (float.TryParse(Convert.ToString(prob["average"]), out sv)) score = sv;
|
|
}
|
|
items.Add(new OcrItem
|
|
{
|
|
Text = Convert.ToString(w.ContainsKey("words") ? w["words"] : ""),
|
|
// location 是发送图的像素坐标,除以缩放比还原到原始裁剪图坐标
|
|
Box = new PointF[]
|
|
{
|
|
new PointF(l / sendScale, t / sendScale),
|
|
new PointF((l + w2) / sendScale, t / sendScale),
|
|
new PointF((l + w2) / sendScale, (t + h) / sendScale),
|
|
new PointF(l / sendScale, (t + h) / sendScale)
|
|
},
|
|
Score = score
|
|
});
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
}
|
|
|
|
/// <summary>平均置信度(无结果时 -1,便于选优时落败)。</summary>
|
|
private static float AvgScore(List<OcrItem> items)
|
|
{
|
|
if (items == null || items.Count == 0) return -1f;
|
|
float sum = 0;
|
|
foreach (var it in items) sum += it.Score;
|
|
return sum / items.Count;
|
|
}
|
|
|
|
/// <summary>把顺/逆时针旋转 90° 后识别出的 Box 坐标逆向还原到原图坐标系
|
|
/// (还原后重排为 左上/右上/右下/左下,Bounds 语义不变)。</summary>
|
|
private static void UnrotateItems(List<OcrItem> items, bool clockwise, int w, int h)
|
|
{
|
|
foreach (var it in items)
|
|
{
|
|
if (it.Box == null) continue;
|
|
float l = float.MaxValue, t = float.MaxValue, r = float.MinValue, b = float.MinValue;
|
|
foreach (var p in it.Box)
|
|
{
|
|
float x, y;
|
|
if (clockwise) { x = p.Y; y = h - 1f - p.X; }
|
|
else { x = w - 1f - p.Y; y = p.X; }
|
|
if (x < l) l = x; if (x > r) r = x;
|
|
if (y < t) t = y; if (y > b) b = y;
|
|
}
|
|
it.Box = new PointF[]
|
|
{
|
|
new PointF(l, t), new PointF(r, t),
|
|
new PointF(r, b), new PointF(l, b)
|
|
};
|
|
}
|
|
}
|
|
|
|
private static float LocF(Dictionary<string, object> loc, string key)
|
|
{
|
|
float v;
|
|
return (loc != null && loc.ContainsKey(key) &&
|
|
float.TryParse(Convert.ToString(loc[key]), out v)) ? v : 0f;
|
|
}
|
|
|
|
/// <summary>编码为百度限额内的 JPEG(高精度版要求 base64 后 ≤4MB):
|
|
/// 质量 90/75/60 递降试编码,均超限则按 0.8 倍降采样重试(最多 8 轮,必然收敛)。
|
|
/// scale=发送图/原图(≤1),调用方用它把返回坐标还原到原图。</summary>
|
|
private static string EncodeFit(Bitmap img, out float scale)
|
|
{
|
|
scale = 1f;
|
|
string last = null;
|
|
for (int round = 0; round < 8; round++)
|
|
{
|
|
int w = Math.Max(1, (int)Math.Round(img.Width * scale));
|
|
int h = Math.Max(1, (int)Math.Round(img.Height * scale));
|
|
using (Bitmap cur = (w == img.Width && h == img.Height) ? new Bitmap(img) : new Bitmap(img, w, h))
|
|
{
|
|
foreach (long q in new long[] { 90, 75, 60 })
|
|
{
|
|
last = EncodeJpeg(cur, q);
|
|
// base64 字符数≈UTF-8 字节数;3.9M 留余量防 4M 硬限被卡
|
|
if (last.Length <= 3900000) return last;
|
|
}
|
|
}
|
|
scale *= 0.8f;
|
|
}
|
|
return last;
|
|
}
|
|
|
|
private static string EncodeJpeg(Bitmap bmp, long quality)
|
|
{
|
|
using (var ms = new MemoryStream())
|
|
{
|
|
var codec = GetJpegEncoder();
|
|
if (codec != null)
|
|
{
|
|
using (var ep = new EncoderParameters(1))
|
|
{
|
|
ep.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, quality);
|
|
bmp.Save(ms, codec, ep);
|
|
}
|
|
}
|
|
else bmp.Save(ms, ImageFormat.Jpeg);
|
|
return Convert.ToBase64String(ms.ToArray());
|
|
}
|
|
}
|
|
|
|
/// <summary>Base64 的 URI 表单转义:仅 + / = 三个字符转 %XX,字母数字无需转义。
|
|
/// 不能用 Uri.EscapeDataString:.NET Framework 对约 32K 以上字符串抛"URI 字符串太长"。</summary>
|
|
private static string EscapeBase64(string s)
|
|
{
|
|
var sb = new StringBuilder(s.Length + s.Length / 2);
|
|
for (int i = 0; i < s.Length; i++)
|
|
{
|
|
char c = s[i];
|
|
if (c == '+') sb.Append("%2B");
|
|
else if (c == '/') sb.Append("%2F");
|
|
else if (c == '=') sb.Append("%3D");
|
|
else sb.Append(c);
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static ImageCodecInfo GetJpegEncoder()
|
|
{
|
|
foreach (var c in ImageCodecInfo.GetImageEncoders())
|
|
if (c.FormatID == ImageFormat.Jpeg.Guid) return c;
|
|
return null;
|
|
}
|
|
|
|
public void Dispose() { }
|
|
}
|
|
}
|