基线:全量源码首提(D1 版本控制落地,含第一轮优化 B1-B4/A1/A4/缩放修复)
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
// ============================================================
|
||||
// OcrClient:OCR 引擎路由(baidu_ocr.json 的 engine 字段选择)
|
||||
// · engine=baidu(默认):百度 OCR 高精度含位置版(HTTP 直连)
|
||||
// · engine=glm:智谱 GLM-OCR 版面解析(手写字强,GlmOcrClient)
|
||||
// · Key 都在 OcrPaths.ConfigPath 的 baidu_ocr.json(OCR设置窗口写):
|
||||
// api_key/secret_key=百度;glm_api_key=智谱
|
||||
// 百度未保存过/Key 为空 → 内置默认 Key 直接可用
|
||||
// · 百度 access_token 缓存同目录 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 成套报价软件
|
||||
{
|
||||
/// <summary>
|
||||
/// OCR 配置/缓存/本体的路径解析(兼容独立版 pdfbt 的旧布局):
|
||||
/// 主程序 exe 旁优先;装过独立版 pdfbt 的机器回退 pdfbt\ 子目录旧文件。
|
||||
/// </summary>
|
||||
public static class OcrPaths
|
||||
{
|
||||
/// <summary>
|
||||
/// baidu_ocr.json:主 exe 旁已存在→直接用;否则看 pdfbt\ 子目录(独立版
|
||||
/// pdfbt.exe 保存过的配置);都没有→主 exe 旁(首次保存的新文件)。
|
||||
/// </summary>
|
||||
public static string ConfigPath
|
||||
{
|
||||
get
|
||||
{
|
||||
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
string p = Path.Combine(baseDir, "baidu_ocr.json");
|
||||
if (File.Exists(p)) return p;
|
||||
string legacy = Path.Combine(baseDir, "pdfbt", "baidu_ocr.json");
|
||||
if (File.Exists(legacy)) return legacy;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>百度 token 缓存,与配置文件同目录(换 Key 后删它重换)。</summary>
|
||||
public static string TokenCachePath
|
||||
{
|
||||
get { return Path.Combine(Path.GetDirectoryName(ConfigPath), "baidu_token.json"); }
|
||||
}
|
||||
}
|
||||
|
||||
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 = OcrPaths.ConfigPath;
|
||||
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 = OcrPaths.TokenCachePath;
|
||||
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() { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user