// ============================================================ // 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 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(); /// 当前 OCR 引擎(baidu/glm/qianfan/uapis/umi)。 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(); } /// 读 baidu_ocr.json(OCR设置窗口保存后调用可热更新 Key/引擎)。 public void LoadConfig() { try { string p = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "baidu_ocr.json"); if (File.Exists(p)) { var jsv = _json.Deserialize>(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); } /// 获取 access_token(内存→文件缓存→在线换取)。失败抛异常。 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>(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>(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 { { "token", _token }, { "expire", (_tokenExpire - new DateTime(1970, 1, 1)).TotalSeconds } }; File.WriteAllText(cachePath, _json.Serialize(cache)); } catch { } return _token; } } /// 识别位图(300DPI 裁剪)。失败抛异常;成功通知引擎来源。 /// 竖排文字(实为旋转 90° 的横排)直接识别必乱码:细高框或平均置信度偏低时, /// 旋转 ±90° 重识别并按平均置信度选优,坐标逆向还原回原图。 public Task> 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>(); 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; } /// 单方向识别一次(编码→上传→解析);Box 为入参图像的像素坐标。 private List 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>(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(); var arr = resp.ContainsKey("words_result") ? resp["words_result"] as System.Collections.ArrayList : null; if (arr != null) { foreach (Dictionary w in arr) { var loc = (w != null && w.ContainsKey("location")) ? w["location"] as Dictionary : 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 : 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; } } /// 平均置信度(无结果时 -1,便于选优时落败)。 private static float AvgScore(List items) { if (items == null || items.Count == 0) return -1f; float sum = 0; foreach (var it in items) sum += it.Score; return sum / items.Count; } /// 把顺/逆时针旋转 90° 后识别出的 Box 坐标逆向还原到原图坐标系 /// (还原后重排为 左上/右上/右下/左下,Bounds 语义不变)。 private static void UnrotateItems(List 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 loc, string key) { float v; return (loc != null && loc.ContainsKey(key) && float.TryParse(Convert.ToString(loc[key]), out v)) ? v : 0f; } /// 编码为百度限额内的 JPEG(高精度版要求 base64 后 ≤4MB): /// 质量 90/75/60 递降试编码,均超限则按 0.8 倍降采样重试(最多 8 轮,必然收敛)。 /// scale=发送图/原图(≤1),调用方用它把返回坐标还原到原图。 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()); } } /// Base64 的 URI 表单转义:仅 + / = 三个字符转 %XX,字母数字无需转义。 /// 不能用 Uri.EscapeDataString:.NET Framework 对约 32K 以上字符串抛"URI 字符串太长"。 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() { } } }